483 lines
16 KiB
TypeScript
Executable File
483 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)
|
|
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 })
|
|
}
|
|
}
|