improve entity/crud generation
This commit is contained in:
parent
0a26f8a28e
commit
d5825644a4
@ -186,23 +186,33 @@ private async generateTableComponent(tableName: string, columns: TableColumn[],
|
|||||||
const singular = this.toSingular(tableName);
|
const singular = this.toSingular(tableName);
|
||||||
const pascal = this.toPascalCase(singular);
|
const pascal = this.toPascalCase(singular);
|
||||||
return {
|
return {
|
||||||
singular: singular,
|
singular: this.toKebabCase(singular),
|
||||||
plural: tableName,
|
plural: this.toKebabCase(tableName),
|
||||||
pascal: pascal,
|
pascal: pascal,
|
||||||
camel: this.toCamelCase(singular),
|
camel: this.toCamelCase(singular),
|
||||||
title: pascal,
|
title: pascal,
|
||||||
kebab: singular,
|
kebab: this.toKebabCase(singular),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private toSingular(name: string): string {
|
private toSingular(name: string): string {
|
||||||
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
|
const kebab = this.toKebabCase(name);
|
||||||
return name.endsWith('s') ? name.slice(0, -1) : 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 {
|
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 {
|
private toCamelCase(text: string): string {
|
||||||
const pascal = this.toPascalCase(text);
|
const pascal = this.toPascalCase(text);
|
||||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,370 +1,131 @@
|
|||||||
// src/services/crud-generator.service.ts
|
// src/services/crud-generator.service.ts
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from './config.service';
|
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 path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { promises as fsPromises } from 'fs';
|
import { promises as fsPromises } from 'fs';
|
||||||
import { ModuleUpdaterService } from './module-updater.service';
|
|
||||||
import { TableColumn } from 'typeorm';
|
|
||||||
|
|
||||||
interface NamingConvention {
|
interface NamingConvention {
|
||||||
|
[key: string]: string;
|
||||||
singular: string;
|
singular: string;
|
||||||
plural: string;
|
plural: string;
|
||||||
pascal: string;
|
pascal: string;
|
||||||
camel: string;
|
camel: string;
|
||||||
searchableFields
|
searchableFields: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CrudGeneratorService {
|
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<void> {
|
public async generate(tableName: string, columns?: TableColumn[]): Promise<void> {
|
||||||
console.log(`Generating CRUD module for table: ${tableName}...`);
|
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 config = this.configService.get();
|
||||||
const serverRoot = path.resolve(process.cwd(), config.server.path);
|
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);
|
const moduleDir = path.join(serverRoot, 'src', names.singular);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
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.generateModuleFile(names, moduleDir);
|
||||||
await this.generateControllerFile(names, moduleDir);
|
await this.generateControllerFile(names, moduleDir);
|
||||||
await this.generateServiceFile(names, moduleDir);
|
await this.generateServiceFile(names, moduleDir);
|
||||||
await this.generateDtoFiles(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 moduleFileName = `${names.plural}.module.ts`;
|
||||||
const fullModulePath = path.join(moduleDir, moduleFileName);
|
const fullModulePath = path.join(moduleDir, moduleFileName);
|
||||||
await this.moduleUpdaterService.addImportToAppModule(`${names.pascal}sModule`, fullModulePath);
|
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! ✨');
|
console.log('\n✨ AppModule has been updated automatically! ✨');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ An error occurred during CRUD generation:`, error.message);
|
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) {
|
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`);
|
const filePath = path.join(moduleDir, `${names.plural}.module.ts`);
|
||||||
await fsPromises.mkdir(moduleDir, { recursive: true });
|
await fsPromises.mkdir(moduleDir, { recursive: true });
|
||||||
fs.writeFileSync(filePath, template);
|
fs.writeFileSync(filePath, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateControllerFile(names: NamingConvention, moduleDir: string) {
|
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`);
|
const filePath = path.join(moduleDir, `${names.plural}.controller.ts`);
|
||||||
await fsPromises.mkdir(moduleDir, { recursive: true });
|
await fsPromises.mkdir(moduleDir, { recursive: true });
|
||||||
fs.writeFileSync(filePath, template);
|
fs.writeFileSync(filePath, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateServiceFile(names: NamingConvention, moduleDir: string) {
|
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`);
|
const filePath = path.join(moduleDir, `${names.plural}.service.ts`);
|
||||||
await fsPromises.mkdir(moduleDir, { recursive: true });
|
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) {
|
private async generateDtoFiles(names: NamingConvention, moduleDir: string) {
|
||||||
const dtoDir = path.join(moduleDir, 'dto');
|
const dtoDir = path.join(moduleDir, 'dto');
|
||||||
await fsPromises.mkdir(dtoDir, { recursive: true });
|
await fsPromises.mkdir(dtoDir, { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(path.join(dtoDir, `create-${names.singular}.dto.ts`), this.getCreateDtoTemplate(names));
|
// Use the pre-calculated path
|
||||||
fs.writeFileSync(path.join(dtoDir, `update-${names.singular}.dto.ts`), this.getUpdateDtoTemplate(names));
|
const dtoNames = { ...names, entityPath: names['entityPathForDtos'] };
|
||||||
fs.writeFileSync(path.join(dtoDir, `query-${names.singular}.dto.ts`), this.getQueryDtoTemplate(names));
|
|
||||||
|
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 ---
|
// --- NAMING HELPERS ---
|
||||||
// (All the template methods from the previous response go here, unchanged)
|
|
||||||
|
|
||||||
private getQueryDtoTemplate(names: NamingConvention): string {
|
private isStringBasedType(dbType: string): boolean {
|
||||||
return `import { IsOptional, IsString, IsNumber, IsIn, IsBoolean } from 'class-validator';
|
return dbType.includes('char') || dbType.includes('text') || dbType === 'uuid';
|
||||||
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<Query${names.pascal}Dto, 'page' | 'limit' | 'sortBy' | 'order'>;
|
|
||||||
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 getNamingConvention(tableName: string, columns?: TableColumn[]): NamingConvention {
|
private getNamingConvention(tableName: string, columns?: TableColumn[]): NamingConvention {
|
||||||
const singular = this.toSingular(tableName);
|
const singular = this.toSingular(tableName);
|
||||||
|
const pascal = this.toPascalCase(singular);
|
||||||
const searchableFields = (columns || [])
|
const searchableFields = (columns || [])
|
||||||
.filter(c => this.isStringBasedType(c.type))
|
.filter(c => this.isStringBasedType(c.type))
|
||||||
.map(c => `'${c.name}'`); // Wrap in quotes for the array
|
.map(c => `'${c.name}'`)
|
||||||
|
.join(',\n ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
singular: singular,
|
singular: this.toKebabCase(singular),
|
||||||
plural: tableName,
|
plural: this.toKebabCase(tableName),
|
||||||
pascal: this.toPascalCase(singular),
|
pascal: pascal,
|
||||||
camel: this.toCamelCase(singular),
|
camel: this.toCamelCase(singular),
|
||||||
searchableFields
|
searchableFields: searchableFields
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private toSingular(name: string): string {
|
private toSingular(name: string): string {
|
||||||
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
|
const kebab = this.toKebabCase(name);
|
||||||
return name.endsWith('s') ? name.slice(0, -1) : 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 {
|
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 {
|
private toCamelCase(text: string): string {
|
||||||
const pascal = this.toPascalCase(text);
|
const pascal = this.toPascalCase(text);
|
||||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -187,6 +187,21 @@ export class ${className} {${props}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toPascalCase(text: string): string {
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -14,8 +14,9 @@ export class {{pascal}}DataProvider implements DataProvider<{{pascal}}> {
|
|||||||
private {{camel}}Service = inject({{pascal}}Service);
|
private {{camel}}Service = inject({{pascal}}Service);
|
||||||
|
|
||||||
getData(options?: GetDataOptions): Observable<GetDataResponse<{{pascal}}>> {
|
getData(options?: GetDataOptions): Observable<GetDataResponse<{{pascal}}>> {
|
||||||
// The generic table's params are compatible with our NestJS Query DTO
|
const {q,page,limit} = options?.params ?? {};
|
||||||
return this.{{camel}}Service.find(options?.params ?? {}).pipe(
|
// The generic table's params are compatible with our NestJS Query DTO
|
||||||
|
return this.{{camel}}Service.search(q ?? '',page,limit, ).pipe(
|
||||||
map((res) => {
|
map((res) => {
|
||||||
// Adapt the paginated response to the GetDataResponse format
|
// Adapt the paginated response to the GetDataResponse format
|
||||||
return { data: res };
|
return { data: res };
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
ActionDefinition,
|
ActionDefinition,
|
||||||
GenericActionColumn,
|
GenericActionColumn,
|
||||||
} from '../../../../components/generic-action-column/generic-action-column';
|
} 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';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
44
src/templates/nestjs/controller.ts.tpl
Normal file
44
src/templates/nestjs/controller.ts.tpl
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/templates/nestjs/create-dto.ts.tpl
Normal file
4
src/templates/nestjs/create-dto.ts.tpl
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { OmitType } from '@nestjs/mapped-types';
|
||||||
|
import { {{pascal}} } from '{{entityPath}}';
|
||||||
|
|
||||||
|
export class Create{{pascal}}Dto extends OmitType({{pascal}}, ['id']) {}
|
||||||
12
src/templates/nestjs/module.ts.tpl
Normal file
12
src/templates/nestjs/module.ts.tpl
Normal file
@ -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 {}
|
||||||
9
src/templates/nestjs/query-dto.ts.tpl
Normal file
9
src/templates/nestjs/query-dto.ts.tpl
Normal file
@ -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';
|
||||||
|
}
|
||||||
101
src/templates/nestjs/service.ts.tpl
Normal file
101
src/templates/nestjs/service.ts.tpl
Normal file
@ -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<Query{{pascal}}Dto, 'page' | 'limit' | 'sortBy' | 'order'>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/templates/nestjs/update-dto.ts.tpl
Normal file
4
src/templates/nestjs/update-dto.ts.tpl
Normal file
@ -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) {}
|
||||||
Loading…
Reference in New Issue
Block a user