Initial commit

This commit is contained in:
Igor Martyniuk
2026-06-25 15:53:49 +03:00
commit 1bb0b7e68a
1517 changed files with 218469 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
import { HttpContext } from '@adonisjs/core/http'
import { join, dirname } from 'node:path'
import { mkdirSync, writeFileSync, readFileSync, existsSync, cpSync } from 'node:fs'
import { modelToSchema } from '#services/swagger_model'
import handlebars from 'handlebars'
import {
getDatabaseTables,
getColumnsForTable,
getForeignKeysReferencing,
} from '#services/database_inspector'
handlebars.registerHelper('eq', (a, b) => a === b)
handlebars.registerHelper('not', (v) => !v)
handlebars.registerHelper('or', (a, b) => a || b)
handlebars.registerHelper('and', (a, b) => a && b)
function renderTemplate(templatePath: string, context: any): string {
const raw = readFileSync(templatePath, 'utf8')
const template = handlebars.compile(raw)
return template(context)
}
export default class MakeModuleController {
capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
camelCase(str: string) {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
}
pascalCase(str: string) {
return this.capitalize(this.camelCase(str))
}
snakeCase(str: string) {
return str.slice(0, str.length - 1)
}
mapDBTypeToTs(type: string): string {
if (type.includes('int')) return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'DateTime'
return 'string'
}
mapDBTypeToValidator(type: string): string {
if (
type.includes('int') ||
type === 'numeric' ||
type === 'decimal' ||
type === 'real' ||
type === 'double precision'
) {
return 'number'
}
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'date'
return 'string'
}
mapDBTypeToFrontend(type: string): string {
if (type.includes('int') || type === 'numeric' || type === 'bigint') return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'IsoDateString'
return 'string'
}
async generateRelationships(table: string) {
const lines: string[] = []
const imports: Set<string> = new Set()
const columns = await getColumnsForTable(table)
const references = await getForeignKeysReferencing(table)
for (const col of columns) {
if (col.column_name.endsWith('_id')) {
const refTable = col.column_name.replace('_id', '')
const refModel = this.capitalize(refTable)
imports.add('belongsTo')
lines.push(
` @belongsTo(() => ${refModel})\n declare ${refTable}: BelongsTo<typeof ${refModel}>`
)
}
}
for (const ref of references) {
const relModel = this.capitalize(ref.table_name)
imports.add('hasMany')
lines.push(
` @hasMany(() => ${relModel})\n declare ${ref.table_name}: HasMany<typeof ${relModel}>`
)
}
return {
imports: Array.from(imports),
code: lines,
}
}
async tables({ response }: HttpContext) {
const tables = await getDatabaseTables()
return response.ok(tables)
}
async generate({ request, response }: HttpContext) {
const {
name,
table,
generate,
form,
formDefinition,
dataTable,
list,
formDialog,
menu,
module,
} = request.only([
'name',
'table',
'generate',
'form',
'formDefinition',
'dataTable',
'list',
'formDialog',
'menu',
'module',
])
const appBase = process.cwd()
const backendTplDir = join(appBase, 'app/templates/backend')
const apiTplDir = join(appBase, 'app/templates/backend/api')
const moduleName = this.capitalize(name)
const modulePath = join(appBase, 'modules', moduleName)
const snake = this.snakeCase(table)
const clientBase = join(appBase, '..', 'apps', 'admin', 'src', 'modules', moduleName)
const templateModuleDir = join(appBase, '..', 'apps', 'admin', 'src', 'modules', '_template')
const paths = {
// backend
controller: join(modulePath, 'controller', `${snake}_controller.ts`),
model: join(modulePath, 'model', `${snake}.ts`),
// validatorCreate: join('app', 'validators', `${snake}_create_validator.ts`),
// validatorUpdate: join('app', 'validators', `${snake}_update_validator.ts`),
validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`),
validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`),
routes: join(modulePath, 'routes.ts'),
policy: join(modulePath, 'policy', `${snake}_policy.ts`),
policiesMain: join(appBase, 'app', 'policies', 'main.ts'),
permissionsConfig: join(modulePath, 'config', 'permissions.ts'),
// API subtree
apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`),
// apiValidatorCreate: join('app', 'validators', `${snake}_create_api_validator.ts`),
// apiValidatorUpdate: join('app', 'validators', `${snake}_update_api_validator.ts`),
apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`),
apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`),
apiRoutes: join(modulePath, 'api', 'routes.ts'),
// tests
testController: join(
appBase,
'tests',
'modules',
moduleName,
`${this.snakeCase(table)}.spec.ts`
),
testApi: join(
appBase,
'tests',
'modules',
moduleName,
`${this.snakeCase(table)}.api.spec.ts`
),
// frontend
clientModule: clientBase,
clientForm: join(clientBase, 'view', 'forms', 'MainForm', 'Component.tsx'),
clientFormDefinition: join(clientBase, 'view', 'forms', 'MainForm', 'form.ts'),
clientDatatable: join(clientBase, 'view', 'widgets', 'DataTable.tsx'),
clientList: join(clientBase, 'pages', 'ListPage', 'index.tsx'),
clientDialog: join(clientBase, 'view', 'widgets', 'ManageTrigger.tsx'),
clientConfig: join(clientBase, 'config', 'meta.ts'),
clientMenu: join(clientBase, 'config', 'menu.ts'),
clientModuleConfig: join(clientBase, 'config', 'module.ts'),
clientEntity: join(clientBase, 'entities', 'Entity.ts'),
}
if (existsSync(templateModuleDir)) {
cpSync(templateModuleDir, paths.clientModule, { recursive: true })
}
Object.values(paths).forEach((filePath) => {
mkdirSync(dirname(filePath), { recursive: true })
})
const columns = await getColumnsForTable(table)
const modelClass = this.capitalize(table)
const relationship = await this.generateRelationships(table)
const enhancedColumns = columns.map((col) => {
const colName = col.column_name
const camelName = this.camelCase(colName)
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
camelCase: camelName,
type,
isPrimary: colName === 'id',
isPassword: colName === 'password',
isEmail: colName === 'email',
isDateTime: ['created_at', 'updated_at'].includes(colName),
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(colName),
isRequired: col.is_nullable === 'NO',
isNullable: col.is_nullable === 'YES',
validatorType: this.mapDBTypeToValidator(col.data_type),
needsTrim:
this.mapDBTypeToValidator(col.data_type) === 'string' &&
colName !== 'email' &&
colName !== 'password',
}
})
const sampleFor = (col: any) => {
const t = col.validatorType
if (t === 'number') return 1
if (t === 'boolean') return false
if (t === 'date') return `'${new Date().toISOString()}'`
return `'sample_${col.camelCase}'`
}
const createFields = enhancedColumns
.filter((c) => !c.isPrimary && !c.isSpecial)
.map((c) => ({
camelCase: c.camelCase,
sampleValue: sampleFor(c),
column_name: c.name,
}))
const updateFields = enhancedColumns
.filter((c) => !c.isPrimary && !c.isDateTime)
.map((c) => ({
camelCase: c.camelCase,
sampleValue: sampleFor(c),
column_name: c.name,
}))
// minimal insert json for show/update/destroy fixtures
const insertJson = `{
${createFields.map((f) => ` ${f.column_name}: ${f.sampleValue}`).join(',\n')}
}`
const frontendFields = columns.map((col) => {
return {
name: col.column_name,
type:
this.mapDBTypeToFrontend(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
}
})
// FRONTEND writer
writeFileSync(paths.clientForm, form ?? '', 'utf8')
writeFileSync(paths.clientFormDefinition, formDefinition ?? '', 'utf8')
writeFileSync(paths.clientDatatable, dataTable ?? '', 'utf8')
writeFileSync(paths.clientList, list ?? '', 'utf8')
writeFileSync(paths.clientDialog, formDialog ?? '', 'utf8')
writeFileSync(paths.clientMenu, menu ?? '', 'utf8')
writeFileSync(paths.clientModuleConfig, module ?? '', 'utf8')
const configContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'config.hbs'), {
name,
})
writeFileSync(paths.clientConfig, configContent)
const entityContent = renderTemplate(join(backendTplDir, '..', 'frontend', 'entity.hbs'), {
fields: frontendFields,
})
writeFileSync(paths.clientEntity, entityContent)
// BACKEND: model
if (generate.includes('model')) {
const content = renderTemplate(join(backendTplDir, 'model.hbs'), {
name,
pascalCaseName: this.pascalCase(name),
capName: this.capitalize(name),
className: modelClass,
imports: relationship.imports.join(', '),
columns: enhancedColumns,
relationships: relationship.code,
hasPassword: columns.some((col) => col.column_name === 'password'),
})
await writeFileSync(paths.model, content)
}
// BACKEND: controller
if (generate.includes('controller')) {
const content = renderTemplate(join(backendTplDir, 'controller.hbs'), {
pascalCaseName: this.pascalCase(name),
modelName: modelClass,
capName: this.capitalize(name),
moduleName,
tableName: table,
snakeName: snake,
name,
})
writeFileSync(paths.controller, content)
const testsTplDir = join(backendTplDir, 'tests')
// controller tests
const ctrlSpec = renderTemplate(join(testsTplDir, 'controller.spec.hbs'), {
pascalCaseName: this.pascalCase(name),
snakeCaseName: this.snakeCase(table),
tableName: table,
createFields,
updateFields,
insertJson,
})
mkdirSync(dirname(paths.testController), { recursive: true })
writeFileSync(paths.testController, ctrlSpec, 'utf8')
}
// BACKEND: routes
if (generate.includes('routes')) {
const content = renderTemplate(join(backendTplDir, 'routes.hbs'), {
name,
capName: this.capitalize(name),
className: moduleName,
routeName: name,
snakeCaseName: snake,
})
writeFileSync(paths.routes, content)
}
// BACKEND: validators
if (generate.includes('validator')) {
const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `create${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial),
})
writeFileSync(paths.validatorCreate, createContent)
const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `update${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime),
})
writeFileSync(paths.validatorUpdate, updateContent)
}
// BACKEND: policy + permissions
if (generate.includes('policy')) {
const policyTpl = join(backendTplDir, 'policy.hbs')
const base = name.toLowerCase()
const slugBase = base.endsWith('s') ? base : `${base}s`
const policyContent = renderTemplate(policyTpl, {
name,
slugBase,
moduleName,
modelName: modelClass,
pascalCaseName: this.pascalCase(name),
})
writeFileSync(paths.policy, policyContent)
if (!existsSync(paths.policiesMain)) {
mkdirSync(dirname(paths.policiesMain), { recursive: true })
writeFileSync(paths.policiesMain, `export const policies = {}\n`)
}
const capName = this.capitalize(name)
const key = `${modelClass}Policy`
const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),`
const current = readFileSync(paths.policiesMain, 'utf8')
if (current.includes('export const policies =')) {
if (!current.includes(importLine)) {
const updated = current.replace(
/export const policies\s*=\s*\{\s*/m,
(m) => m + '\n' + importLine + '\n'
)
writeFileSync(paths.policiesMain, updated)
}
} else {
writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`)
}
const labelBase = this.capitalize(name.endsWith('s') ? name.slice(0, -1) : name)
const perms = [
{ action: 'view', slug: `${name}s.view`, name: `${labelBase} view` },
{ action: 'create', slug: `${name}s.create`, name: `${labelBase} create` },
{ action: 'update', slug: `${name}s.update`, name: `${labelBase} update` },
{ action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` },
]
const fileContents =
'export default [\n' +
perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') +
'\n]\n'
if (!existsSync(paths.permissionsConfig)) {
mkdirSync(dirname(paths.permissionsConfig), { recursive: true })
writeFileSync(paths.permissionsConfig, fileContents, 'utf8')
}
}
// API subtree
if (generate.includes('api')) {
const { default: genModel } = await import(paths.model)
const test = await modelToSchema(genModel)
console.warn(test)
const apiCtx = {
pascalCaseName: this.pascalCase(name),
modelName: modelClass,
capName: this.capitalize(name),
moduleName,
tableName: table,
snakeCaseName: snake,
name,
snake,
swaggerModel: await modelToSchema(genModel),
}
// Controller
const apiControllerTpl = join(apiTplDir, 'controller.hbs')
const apiControllerContent = renderTemplate(apiControllerTpl, apiCtx)
writeFileSync(paths.apiController, apiControllerContent, 'utf8')
// test
const testsTplDir = join(backendTplDir, 'tests')
const apiSpec = renderTemplate(join(testsTplDir, 'api.spec.hbs'), {
snakeCaseName: this.snakeCase(table),
createFields,
})
writeFileSync(paths.testApi, apiSpec, 'utf8')
// Validators
const apiValidatorTpl = join(apiTplDir, 'validator.hbs')
const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `create${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial),
})
writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8')
const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `update${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime),
})
writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8')
// Routes
const apiRoutesTpl = join(apiTplDir, 'routes.hbs')
const apiRoutesContent = renderTemplate(apiRoutesTpl, apiCtx)
writeFileSync(paths.apiRoutes, apiRoutesContent, 'utf8')
}
return response.ok({ message: `Module "${moduleName}" created based on table "${table}"` })
}
public async columns({ request, response }: HttpContext) {
const { table } = request.params()
const columns = await getColumnsForTable(table)
const enhancedColumns = columns.map((col) => {
const colName = col.column_name
const type = this.mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
type,
isDateTime: ['created_at', 'updated_at'].includes(colName),
}
})
return response.ok({ columns: enhancedColumns })
}
}