Skip to content

Commit d66f850

Browse files
committed
feat: add support for applying mixins to a model
1 parent 1c3a8e8 commit d66f850

3 files changed

Lines changed: 542 additions & 5 deletions

File tree

src/code_transformer/main.ts

Lines changed: 168 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { join } from 'node:path'
1111
import { fileURLToPath } from 'node:url'
12+
import { ImportsBag } from '@poppinss/utils'
1213
import { installPackage, detectPackageManager } from '@antfu/install-pkg'
1314
import {
1415
Node,
@@ -27,6 +28,7 @@ import type {
2728
EnvValidationNode,
2829
BouncerPolicyNode,
2930
ValidatorNode,
31+
MixinDefinition,
3032
} from '../types/code_transformer.ts'
3133

3234
/**
@@ -115,6 +117,7 @@ export class CodeTransformer {
115117
tests: rcFileTransformer.getDirectory('tests', 'tests'),
116118
policies: rcFileTransformer.getDirectory('policies', 'app/policies'),
117119
validators: rcFileTransformer.getDirectory('validators', 'app/validators'),
120+
models: rcFileTransformer.getDirectory('models', 'app/models'),
118121
}
119122
}
120123

@@ -237,12 +240,10 @@ export class CodeTransformer {
237240
file: SourceFile,
238241
importDeclarations: { isNamed: boolean; module: string; identifier: string }[]
239242
) {
240-
const existingImports = file.getImportDeclarations()
241-
242243
importDeclarations.forEach((importDeclaration) => {
243-
const existingImport = existingImports.find(
244-
(mod) => mod.getModuleSpecifierValue() === importDeclaration.module
245-
)
244+
const existingImport = file
245+
.getImportDeclarations()
246+
.find((mod) => mod.getModuleSpecifierValue() === importDeclaration.module)
246247

247248
/**
248249
* Add a new named import to existing import for the
@@ -635,4 +636,166 @@ export class CodeTransformer {
635636
file.formatText(this.#editorSettings)
636637
await file.save()
637638
}
639+
640+
async addModelMixins(modelFileName: string, mixins: MixinDefinition[]) {
641+
const directories = this.getDirectories()
642+
const filePath = `${directories.models}/${modelFileName}`
643+
644+
/**
645+
* Get the model file URL
646+
*/
647+
const modelFileUrl = join(this.#cwdPath, `./${filePath}`)
648+
let file = this.project.getSourceFile(modelFileUrl)
649+
650+
/**
651+
* Try to load the file from disk if not already in the project
652+
*/
653+
if (!file) {
654+
try {
655+
file = this.project.addSourceFileAtPath(modelFileUrl)
656+
} catch {
657+
throw new Error(`Could not find source file at path: "${filePath}"`)
658+
}
659+
}
660+
661+
/**
662+
* Get the default export class declaration
663+
*/
664+
const defaultExportSymbol = file.getDefaultExportSymbol()
665+
if (!defaultExportSymbol) {
666+
throw new Error(
667+
`Could not find default export in "${filePath}". The model must have a default export class.`
668+
)
669+
}
670+
671+
const declarations = defaultExportSymbol.getDeclarations()
672+
if (declarations.length === 0) {
673+
throw new Error(`Could not find default export declaration in "${filePath}".`)
674+
}
675+
676+
const declaration = declarations[0]
677+
if (!Node.isClassDeclaration(declaration)) {
678+
throw new Error(
679+
`Default export in "${filePath}" is not a class. The model must be exported as a class.`
680+
)
681+
}
682+
683+
/**
684+
* Use ImportsBag to properly manage and merge imports
685+
*/
686+
const importsBag = new ImportsBag()
687+
for (const mixin of mixins) {
688+
if (mixin.importType === 'named') {
689+
importsBag.add({ source: mixin.importPath, namedImports: [mixin.name] })
690+
} else {
691+
importsBag.add({ source: mixin.importPath, defaultImport: mixin.name })
692+
}
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+
)
718+
})
719+
this.#addImportDeclarations(file, importDeclarations)
720+
721+
/**
722+
* Get the heritage clause (extends clause)
723+
*/
724+
const heritageClause = declaration.getHeritageClauseByKind(SyntaxKind.ExtendsKeyword)
725+
if (!heritageClause) {
726+
throw new Error(`Could not find extends clause in "${filePath}".`)
727+
}
728+
729+
const extendsExpression = heritageClause.getTypeNodes()[0]
730+
if (!extendsExpression) {
731+
throw new Error(`Could not find extends expression in "${filePath}".`)
732+
}
733+
734+
/**
735+
* Get the expression that the class extends
736+
*/
737+
const extendsExpressionNode = extendsExpression.getExpression()
738+
739+
/**
740+
* Check if the class already uses compose
741+
*/
742+
let composeCall: Node | undefined
743+
if (Node.isCallExpression(extendsExpressionNode)) {
744+
const callExpression = extendsExpressionNode.getExpression()
745+
if (callExpression.getText() === 'compose') {
746+
composeCall = extendsExpressionNode
747+
}
748+
}
749+
750+
/**
751+
* Build the mixin calls
752+
*/
753+
const mixinCalls = mixins.map((mixin) => {
754+
const args = mixin.args && mixin.args.length > 0 ? mixin.args.join(', ') : ''
755+
return `${mixin.name}(${args})`
756+
})
757+
758+
/**
759+
* If the class is already using compose, add the mixins to the compose call
760+
*/
761+
if (composeCall && Node.isCallExpression(composeCall)) {
762+
const existingArgs = composeCall.getArguments()
763+
const existingArgsText = existingArgs.map((arg) => arg.getText())
764+
765+
/**
766+
* Filter out mixins that are already applied by checking if a call
767+
* to the mixin function already exists in the compose arguments
768+
*/
769+
const newMixinCalls = mixinCalls.filter((mixinCall) => {
770+
// Extract the function name from the mixin call (e.g., "withManagedEmail" from "withManagedEmail()")
771+
const mixinFunctionName = mixinCall.split('(')[0]
772+
// Check if any existing arg contains a call to this function
773+
return !existingArgsText.some((existingArg) => {
774+
return existingArg.includes(`${mixinFunctionName}(`)
775+
})
776+
})
777+
778+
const newArgs = [...existingArgsText, ...newMixinCalls]
779+
composeCall.replaceWithText(`compose(${newArgs.join(', ')})`)
780+
} else {
781+
/**
782+
* If the class is not using compose, wrap the extends expression in compose
783+
* and add import for compose
784+
*/
785+
this.#addImportDeclarations(file, [
786+
{
787+
isNamed: true,
788+
module: '@adonisjs/core/helpers',
789+
identifier: 'compose',
790+
},
791+
])
792+
793+
const currentExtends = extendsExpressionNode.getText()
794+
const newExtends = `compose(${currentExtends}, ${mixinCalls.join(', ')})`
795+
extendsExpression.replaceWithText(newExtends)
796+
}
797+
798+
file.formatText(this.#editorSettings)
799+
await file.save()
800+
}
638801
}

src/types/code_transformer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ export type ValidatorNode = {
112112
contents: string
113113
}
114114

115+
export type MixinDefinition = {
116+
name: string
117+
args?: string[]
118+
importPath: string
119+
importType: 'named' | 'default'
120+
}
121+
115122
export type HookNode =
116123
| {
117124
type: 'thunk'

0 commit comments

Comments
 (0)