Initial commit
This commit is contained in:
Executable
+482
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user