Skip to content

Commit adfc592

Browse files
committed
feat: add codetransformer.addControllerMethod codemod
1 parent d66f850 commit adfc592

3 files changed

Lines changed: 296 additions & 32 deletions

File tree

src/code_transformer/main.ts

Lines changed: 130 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { join } from 'node:path'
1111
import { fileURLToPath } from 'node:url'
12-
import { ImportsBag } from '@poppinss/utils'
12+
import { type ImportInfo, ImportsBag } from '@poppinss/utils'
1313
import { installPackage, detectPackageManager } from '@antfu/install-pkg'
1414
import {
1515
Node,
@@ -29,6 +29,7 @@ import type {
2929
BouncerPolicyNode,
3030
ValidatorNode,
3131
MixinDefinition,
32+
ControllerMethodNode,
3233
} from '../types/code_transformer.ts'
3334

3435
/**
@@ -118,6 +119,7 @@ export class CodeTransformer {
118119
policies: rcFileTransformer.getDirectory('policies', 'app/policies'),
119120
validators: rcFileTransformer.getDirectory('validators', 'app/validators'),
120121
models: rcFileTransformer.getDirectory('models', 'app/models'),
122+
controllers: rcFileTransformer.getDirectory('controllers', 'app/controllers'),
121123
}
122124
}
123125

@@ -278,6 +280,39 @@ export class CodeTransformer {
278280
})
279281
}
280282

283+
/**
284+
* Convert ImportInfo array to import declarations and add them to the file
285+
*/
286+
#addImportsFromImportInfo(file: SourceFile, imports: ImportInfo[]) {
287+
const importsBag = new ImportsBag()
288+
for (const importInfo of imports) {
289+
importsBag.add(importInfo)
290+
}
291+
292+
const importDeclarations = importsBag.toArray().flatMap((moduleImport) => {
293+
return (moduleImport.namedImports ?? [])
294+
.map((symbol) => {
295+
return {
296+
isNamed: true,
297+
module: moduleImport.source,
298+
identifier: symbol,
299+
}
300+
})
301+
.concat(
302+
moduleImport.defaultImport
303+
? [
304+
{
305+
isNamed: false,
306+
module: moduleImport.source,
307+
identifier: moduleImport.defaultImport,
308+
},
309+
]
310+
: []
311+
)
312+
})
313+
this.#addImportDeclarations(file, importDeclarations)
314+
}
315+
281316
/**
282317
* Write a leading comment
283318
*/
@@ -681,42 +716,16 @@ export class CodeTransformer {
681716
}
682717

683718
/**
684-
* Use ImportsBag to properly manage and merge imports
719+
* Add import declarations for the mixins
685720
*/
686-
const importsBag = new ImportsBag()
687-
for (const mixin of mixins) {
721+
const mixinImports = mixins.map((mixin) => {
688722
if (mixin.importType === 'named') {
689-
importsBag.add({ source: mixin.importPath, namedImports: [mixin.name] })
723+
return { source: mixin.importPath, namedImports: [mixin.name] }
690724
} else {
691-
importsBag.add({ source: mixin.importPath, defaultImport: mixin.name })
725+
return { source: mixin.importPath, defaultImport: mixin.name }
692726
}
693-
}
694-
695-
/**
696-
* Add import declarations for the mixins
697-
*/
698-
const importDeclarations = importsBag.toArray().flatMap((moduleImport) => {
699-
return (moduleImport.namedImports ?? [])
700-
.map((symbol) => {
701-
return {
702-
isNamed: true,
703-
module: moduleImport.source,
704-
identifier: symbol,
705-
}
706-
})
707-
.concat(
708-
moduleImport.defaultImport
709-
? [
710-
{
711-
isNamed: false,
712-
module: moduleImport.source,
713-
identifier: moduleImport.defaultImport,
714-
},
715-
]
716-
: []
717-
)
718727
})
719-
this.#addImportDeclarations(file, importDeclarations)
728+
this.#addImportsFromImportInfo(file, mixinImports)
720729

721730
/**
722731
* Get the heritage clause (extends clause)
@@ -798,4 +807,93 @@ export class CodeTransformer {
798807
file.formatText(this.#editorSettings)
799808
await file.save()
800809
}
810+
811+
async addControllerMethod(definition: ControllerMethodNode) {
812+
const directories = this.getDirectories()
813+
const filePath = `${directories.controllers}/${definition.controllerFileName}`
814+
815+
/**
816+
* Get the controller file URL
817+
*/
818+
const controllerFileUrl = join(this.#cwdPath, `./${filePath}`)
819+
let file = this.project.getSourceFile(controllerFileUrl)
820+
821+
/**
822+
* Try to load the file from disk if not already in the project
823+
*/
824+
if (!file) {
825+
try {
826+
file = this.project.addSourceFileAtPath(controllerFileUrl)
827+
} catch {
828+
// File does not exist on disk, we will create it
829+
}
830+
}
831+
832+
/**
833+
* If the file does not exist, create it with the controller class and method
834+
*/
835+
if (!file) {
836+
const contents = `export default class ${definition.className} {
837+
${definition.contents}
838+
}`
839+
840+
file = this.project.createSourceFile(controllerFileUrl, contents)
841+
842+
/**
843+
* Add imports if specified
844+
*/
845+
if (definition.imports) {
846+
this.#addImportsFromImportInfo(file, definition.imports)
847+
}
848+
849+
file.formatText(this.#editorSettings)
850+
await file.save()
851+
return
852+
}
853+
854+
/**
855+
* Get the default export class declaration
856+
*/
857+
const defaultExportSymbol = file.getDefaultExportSymbol()
858+
if (!defaultExportSymbol) {
859+
throw new Error(
860+
`Could not find default export in "${filePath}". The controller must have a default export class.`
861+
)
862+
}
863+
864+
const declarations = defaultExportSymbol.getDeclarations()
865+
if (declarations.length === 0) {
866+
throw new Error(`Could not find default export declaration in "${filePath}".`)
867+
}
868+
869+
const declaration = declarations[0]
870+
if (!Node.isClassDeclaration(declaration)) {
871+
throw new Error(
872+
`Default export in "${filePath}" is not a class. The controller must be exported as a class.`
873+
)
874+
}
875+
876+
/**
877+
* Check if the method already exists
878+
*/
879+
const existingMethod = declaration.getMethod(definition.name)
880+
if (existingMethod) {
881+
return
882+
}
883+
884+
/**
885+
* Add imports if specified
886+
*/
887+
if (definition.imports) {
888+
this.#addImportsFromImportInfo(file, definition.imports)
889+
}
890+
891+
/**
892+
* Add the method to the class by inserting the raw method text
893+
*/
894+
declaration.addMember(definition.contents)
895+
896+
file.formatText(this.#editorSettings)
897+
await file.save()
898+
}
801899
}

src/types/code_transformer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* file that was distributed with this source code.
88
*/
99

10+
import { type ImportInfo } from '@poppinss/utils'
11+
1012
/**
1113
* Entry to add a middleware to a given middleware stack via the CodeTransformer.
1214
* Represents middleware configuration for server, router, or named middleware stacks.
@@ -119,6 +121,14 @@ export type MixinDefinition = {
119121
importType: 'named' | 'default'
120122
}
121123

124+
export type ControllerMethodNode = {
125+
controllerFileName: string
126+
className: string
127+
name: string
128+
contents: string
129+
imports?: ImportInfo[]
130+
}
131+
122132
export type HookNode =
123133
| {
124134
type: 'thunk'

tests/code_transformer.spec.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1791,3 +1791,159 @@ test.group('Code Transformer | addModelMixins', (group) => {
17911791
`)
17921792
})
17931793
})
1794+
1795+
test.group('Code Transformer | addControllerMethod', (group) => {
1796+
group.each.setup(async ({ context }) => setupFakeAdonisproject(context.fs))
1797+
1798+
test('add controller method to an existing controller file', async ({ assert, fs }) => {
1799+
const transformer = new CodeTransformer(fs.baseUrl)
1800+
await fs.create(
1801+
'app/controllers/users_controller.ts',
1802+
dedent`
1803+
export default class UsersController {
1804+
}
1805+
`
1806+
)
1807+
1808+
await transformer.addControllerMethod({
1809+
imports: [
1810+
{
1811+
source: '#validators/user',
1812+
namedImports: ['changePasswordValidator'],
1813+
},
1814+
],
1815+
contents: dedent`async update({ response, auth, session, request }: HttpContext) {
1816+
const payload = await request.validateUsing(changePasswordValidator)
1817+
1818+
const user = auth.getUserOrFail()
1819+
await user.validatePassword(payload.currentPassword)
1820+
1821+
user.password = payload.password
1822+
await user.save()
1823+
1824+
session.flash('success', 'Password updated successfully')
1825+
response.redirect().back()
1826+
}`,
1827+
controllerFileName: 'users_controller.ts',
1828+
className: 'UsersController',
1829+
name: 'update',
1830+
})
1831+
1832+
const file = await fs.contents('app/controllers/users_controller.ts')
1833+
assert.snapshot(file).matchInline(`
1834+
"import { changePasswordValidator } from '#validators/user'
1835+
1836+
export default class UsersController {
1837+
async update({ response, auth, session, request }: HttpContext) {
1838+
const payload = await request.validateUsing(changePasswordValidator)
1839+
1840+
const user = auth.getUserOrFail()
1841+
await user.validatePassword(payload.currentPassword)
1842+
1843+
user.password = payload.password
1844+
await user.save()
1845+
1846+
session.flash('success', 'Password updated successfully')
1847+
response.redirect().back()
1848+
}
1849+
}
1850+
"
1851+
`)
1852+
})
1853+
1854+
test('create controller when it does not exist', async ({ assert, fs }) => {
1855+
const transformer = new CodeTransformer(fs.baseUrl)
1856+
1857+
await transformer.addControllerMethod({
1858+
imports: [
1859+
{
1860+
source: '#validators/user',
1861+
namedImports: ['changePasswordValidator'],
1862+
},
1863+
],
1864+
contents: dedent`async update({ response, auth, session, request }: HttpContext) {
1865+
const payload = await request.validateUsing(changePasswordValidator)
1866+
1867+
const user = auth.getUserOrFail()
1868+
await user.validatePassword(payload.currentPassword)
1869+
1870+
user.password = payload.password
1871+
await user.save()
1872+
1873+
session.flash('success', 'Password updated successfully')
1874+
response.redirect().back()
1875+
}`,
1876+
controllerFileName: 'users_controller.ts',
1877+
className: 'UsersController',
1878+
name: 'update',
1879+
})
1880+
1881+
const file = await fs.contents('app/controllers/users_controller.ts')
1882+
assert.snapshot(file).matchInline(`
1883+
"import { changePasswordValidator } from '#validators/user'
1884+
1885+
export default class UsersController {
1886+
async update({ response, auth, session, request }: HttpContext) {
1887+
const payload = await request.validateUsing(changePasswordValidator)
1888+
1889+
const user = auth.getUserOrFail()
1890+
await user.validatePassword(payload.currentPassword)
1891+
1892+
user.password = payload.password
1893+
await user.save()
1894+
1895+
session.flash('success', 'Password updated successfully')
1896+
response.redirect().back()
1897+
}
1898+
}
1899+
"
1900+
`)
1901+
})
1902+
1903+
test('skip when method already exists', async ({ assert, fs }) => {
1904+
const transformer = new CodeTransformer(fs.baseUrl)
1905+
await fs.create(
1906+
'app/controllers/users_controller.ts',
1907+
dedent`
1908+
export default class UsersController {
1909+
async update({ response, auth, session, request }: HttpContext) {
1910+
// todo
1911+
}
1912+
}
1913+
`
1914+
)
1915+
1916+
await transformer.addControllerMethod({
1917+
imports: [
1918+
{
1919+
source: '#validators/user',
1920+
namedImports: ['changePasswordValidator'],
1921+
},
1922+
],
1923+
contents: dedent`async update({ response, auth, session, request }: HttpContext) {
1924+
const payload = await request.validateUsing(changePasswordValidator)
1925+
1926+
const user = auth.getUserOrFail()
1927+
await user.validatePassword(payload.currentPassword)
1928+
1929+
user.password = payload.password
1930+
await user.save()
1931+
1932+
session.flash('success', 'Password updated successfully')
1933+
response.redirect().back()
1934+
}`,
1935+
controllerFileName: 'users_controller.ts',
1936+
className: 'UsersController',
1937+
name: 'update',
1938+
})
1939+
1940+
const file = await fs.contents('app/controllers/users_controller.ts')
1941+
assert.snapshot(file).matchInline(`
1942+
"export default class UsersController {
1943+
async update({ response, auth, session, request }: HttpContext) {
1944+
// todo
1945+
}
1946+
}"
1947+
`)
1948+
})
1949+
})

0 commit comments

Comments
 (0)