Last active
April 22, 2024 15:47
-
-
Save ezzabuzaid/1254c2bc74c13f18940c9256bd14ae27 to your computer and use it in GitHub Desktop.
Migrate constructor injection to the new inject function.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as morph from 'ts-morph'; | |
/** | |
* By default any component, directive, pipe, or service that have superclass is discarded | |
* | |
* If you want to permit some superclasses. | |
*/ | |
const ALLOWED_SUPER_CLASSES: string[] = []; | |
/** | |
* Migrate constructor injection to the new inject function. | |
* | |
* Private and protected properties will use javascript private fields. | |
*/ | |
export function migrate(angularFiles: morph.SourceFile[]) { | |
// Iterate through each component file | |
for (const file of angularFiles) { | |
const angularClasses = file | |
.getDescendantsOfKind(morph.SyntaxKind.ClassDeclaration) | |
.filter((classDecl) => | |
['Component', 'Pipe', 'Directive', 'Injectable'].some((it) => | |
classDecl.getDecorator(it) | |
) | |
); | |
let qulaifiedForMigration = false; | |
for (const clazz of angularClasses) { | |
// Skip abstract classes | |
if (clazz.getAbstractKeyword()) { | |
continue; | |
} | |
if (clazz.getConstructors().length > 1) { | |
// Unknwon constructor signature | |
continue; | |
} | |
if (clazz.getConstructors().length < 1) { | |
// Skip if no constructor is defined | |
continue; | |
} | |
const extendsExpr = clazz.getExtends(); | |
// allow only BaseComponent | |
if ( | |
extendsExpr && | |
!ALLOWED_SUPER_CLASSES.includes(extendsExpr.getText()) | |
) { | |
// Skip if class extends another class | |
continue; | |
} | |
const [cstr] = clazz.getConstructors(); | |
let upgradedParams: { | |
newName: string; | |
oldName: string; | |
parameter: morph.ParameterDeclaration; | |
isPrivate: boolean; | |
}[] = []; | |
const fixes: { | |
isPublic: boolean; | |
fix: (insertAt: number) => void; | |
}[] = []; | |
for (const parameter of cstr.getParameters()) { | |
// get a clone because we will be modifying the modifiers | |
const modifiers = parameter.getModifiers().slice(0); | |
if (modifiers.length < 1) { | |
// Skip if the parameter has no modifiers | |
continue; | |
} | |
if ( | |
['abstract', 'override', 'accessor'].some((it) => | |
parameter.hasModifier(it as any) | |
) | |
) { | |
// Skip if the parameter is abstract, override, or an accessor | |
continue; | |
} | |
const injectDecorator = parameter.getDecorator('Inject'); | |
const isOptional = consumeModifier(modifiers, '@Optional()'); | |
const isSelf = consumeModifier(modifiers, '@Self()'); | |
const isSkipSelf = consumeModifier(modifiers, '@SkipSelf()'); | |
const isHost = consumeModifier(modifiers, '@Host()'); | |
const isReadonly = consumeModifier(modifiers, 'readonly'); | |
const isPrivate = consumeModifier(modifiers, 'private'); | |
const isProtected = consumeModifier(modifiers, 'protected'); | |
const isPublic = consumeModifier(modifiers, 'public'); | |
const injectOptions = [ | |
isOptional ? 'optional: true' : null, | |
isSelf ? 'self: true' : null, | |
isSkipSelf ? 'skipSelf: true' : null, | |
isHost ? 'host: true' : null | |
].filter((it) => it !== null); | |
// ensure modifers are known (e.g., public, private, protected) | |
// if parameter doesn't have access modifier, skip it | |
// At this point there will be access modifier but just to be safe | |
if (!(isPrivate || isProtected || isPublic)) { | |
continue; | |
} | |
const propertyName = | |
isPrivate || isProtected | |
? `#${parameter.getName().replace('_', '')}` | |
: parameter.getName(); | |
if (injectDecorator) { | |
const callExpr = injectDecorator.getExpressionIfKind( | |
morph.SyntaxKind.CallExpression | |
); | |
if (!callExpr) { | |
// Skip if the @Inject decorator is not a call expression | |
// e.g. @Inject | |
console.warn( | |
`@Inject decorator is not a call expression: ${injectDecorator.getText()}`, | |
`${file.getFilePath()}:${parameter.getPos()}` | |
); | |
continue; | |
} | |
const token = callExpr.getArguments()[0]; | |
if (!token) { | |
console.warn( | |
`@Inject decorator does not have a type argument: ${injectDecorator.getText()}`, | |
`${file.getFilePath()}:${parameter.getPos()}` | |
); | |
continue; | |
} | |
qulaifiedForMigration = true; | |
const typeWithArguments = parameter.getTypeNode()?.getText(); | |
// Ideally there should always be type for @Inject decorator | |
// but to make it compatible with the current codebase | |
// we will ignore it. | |
const genericParam = typeWithArguments | |
? `<${typeWithArguments}>` | |
: ''; | |
fixes.push({ | |
isPublic: !!isPublic, | |
fix: (insertAt) => { | |
const newProperty = clazz.insertProperty(insertAt, { | |
name: propertyName, | |
isReadonly: true, | |
initializer: `inject${genericParam}(${token.getText()}, ${ | |
injectOptions.length > 0 ? `{${injectOptions}}` : '' | |
})` | |
}); | |
newProperty.toggleModifier('public', !!isPublic); | |
} | |
}); | |
} else { | |
const token = parameter.getTypeNode(); | |
const typeWithArguments = parameter.getTypeNode()?.getText(); | |
const typeName = typeWithArguments | |
? typeWithArguments.split('<')[0] | |
: undefined; | |
const genericParam = | |
typeName !== token?.getText() ? `<${typeWithArguments}>` : ''; | |
const tokenName = typeName ?? token?.getText?.(); | |
// type without generic param | |
qulaifiedForMigration = true; | |
fixes.push({ | |
isPublic: !!isPublic, | |
fix: (insertAt) => { | |
const newProperty = clazz.insertProperty(insertAt, { | |
name: propertyName, | |
isReadonly: true, | |
initializer: `inject${genericParam}(${tokenName}, ${ | |
injectOptions.length > 0 ? `{${injectOptions}}` : '' | |
})` | |
}); | |
newProperty.toggleModifier('public', !!isPublic); | |
} | |
}); | |
} | |
upgradedParams = [ | |
...upgradedParams, | |
{ | |
oldName: parameter.getName(), | |
newName: propertyName, | |
parameter, | |
isPrivate: !!isPrivate | |
} | |
]; | |
} | |
// public members should be after last public decoratord member whether get or set | |
fixes.forEach((it) => { | |
if (!it.isPublic) { | |
it.fix(0); | |
} else { | |
const insertAt = clazz | |
.getMembers() | |
.slice() | |
.reverse() | |
.findIndex((member) => { | |
const prop = member.isKind(morph.SyntaxKind.PropertyDeclaration); | |
return ( | |
prop && | |
member.getDecorators().length && | |
member.hasModifier(morph.SyntaxKind.PublicKeyword) | |
); | |
}); | |
it.fix(insertAt === -1 ? 0 : insertAt + 1); | |
} | |
}); | |
{ | |
const identifiers = clazz | |
.getDescendantsOfKind(morph.SyntaxKind.Identifier) | |
.filter( | |
(it) => | |
!it.getParentIfKind(morph.SyntaxKind.Parameter) && | |
!it.getParentIfKind(morph.SyntaxKind.PropertyDeclaration) | |
) | |
.map((it) => ({ | |
node: it, | |
name: it.getText() | |
})); | |
for (const param of upgradedParams) { | |
for (const identifier of identifiers) { | |
if (identifier.name === param.oldName) { | |
const alreadyHaveThis = identifier.node.getFirstAncestorByKind( | |
morph.SyntaxKind.Constructor | |
) | |
? identifier.node | |
.getParentIfKind(morph.SyntaxKind.PropertyAccessExpression) | |
?.getExpression() | |
.getText() === 'this' | |
: true; // if the identifier not in a constructor, we can assume it's already qualified with "this" | |
if (alreadyHaveThis) { | |
identifier.node.replaceWithText(param.newName); | |
} else { | |
identifier.node.replaceWithText(`this.${param.newName}`); | |
} | |
} | |
} | |
} | |
} | |
// Constructor body migration | |
{ | |
// loop over body statements and prepend "this" to any property access that depended on a parameter | |
const body = cstr.getBody() ?? { getDescendantStatements: () => [] }; | |
const statements = body.getDescendantStatements(); | |
// Remove the old parameters | |
upgradedParams.forEach((it) => it.parameter.remove()); | |
// Remove super call | |
const superCall = statements.find( | |
(statement) => | |
statement.getKindName() === 'ExpressionStatement' && | |
statement.getText().includes('super') | |
); | |
if ( | |
cstr.getStatements().length === 1 && | |
superCall && | |
morph.Node.isStatement(superCall) | |
) { | |
superCall.remove(); | |
} | |
removeEmptyCstr(clazz); | |
} | |
} | |
if (qulaifiedForMigration) { | |
setImports(file, [['@angular/core', ['inject']]]); | |
file.saveSync(); | |
} | |
} | |
} | |
/// | |
// Utility functions for the migration | |
/// | |
function consumeModifier( | |
modifiers: morph.Node<morph.ts.Modifier>[], | |
name: string | |
) { | |
const index = modifiers.findIndex((dec) => dec.getText() === name); | |
if (index === -1) { | |
return undefined; | |
} | |
return modifiers.splice(index, 1)[0]; | |
} | |
export function setImports( | |
sourceFile: morph.SourceFile, | |
imports: [string, string[]][] | |
): void { | |
imports.forEach(([moduleSpecifier, namedImports]) => { | |
const moduleSpecifierImport = | |
sourceFile | |
.getImportDeclarations() | |
.find((imp) => imp.getModuleSpecifierValue() === moduleSpecifier) ?? | |
sourceFile.addImportDeclaration({ | |
moduleSpecifier | |
}); | |
const missingNamedImports = namedImports.filter( | |
(namedImport) => | |
!moduleSpecifierImport | |
.getNamedImports() | |
.some((imp) => imp.getName() === namedImport) | |
); | |
moduleSpecifierImport.addNamedImports(missingNamedImports); | |
}); | |
} | |
export function createProject( | |
filesPattern = 'component|directive|pipe|service' | |
) { | |
const [filesPathRelativeToWorkspace] = process.argv.slice(2); | |
if ([undefined, null, ''].includes(filesPathRelativeToWorkspace)) { | |
throw new Error( | |
`Please provide the path to the files you want to migrate as the first argument. | |
e.g npx ./morph-inject.ts src/app | |
` | |
); | |
} | |
const project = new morph.Project({ | |
tsConfigFilePath: './tsconfig.json' | |
}); | |
// Get all the source files that match the angular pattern (e.g., "*.component.ts") | |
const files = project.getSourceFiles( | |
`${filesPathRelativeToWorkspace}/**/*.+(${filesPattern}).ts` | |
); | |
return { | |
files, | |
project | |
}; | |
} | |
export function removeEmptyCstr(clazz: morph.ClassDeclaration) { | |
const [cstr] = clazz.getConstructors(); | |
// Remove the constructor if it has no body | |
if ( | |
cstr && | |
cstr.getParameters().length === 0 && | |
cstr.getStatements().length === 0 | |
) { | |
cstr.remove(); | |
} | |
} | |
/// RUN THE MIGRATION | |
const { files } = createProject(); | |
migrate(files); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Run the command with directory that you want to migrate
npx tsx morph-inject.ts ./src # src is folder in Angular project
Make sure to build the app after running the migration just in case something gone wrong