99
1010import { join } from 'node:path'
1111import { fileURLToPath } from 'node:url'
12+ import { ImportsBag } from '@poppinss/utils'
1213import { installPackage , detectPackageManager } from '@antfu/install-pkg'
1314import {
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}
0 commit comments