Files
admin/apps/backend/app/controllers/module_controller.ts
T

484 lines
16 KiB
TypeScript
Executable File

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)
// Backend and admin are sibling workspaces under apps/.
const clientBase = join(appBase, '..', 'admin', 'src', 'modules', moduleName)
const templateModuleDir = join(appBase, '..', '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 })
}
}