Skip to content

Instantly share code, notes, and snippets.

@ezzabuzaid
Last active April 22, 2024 15:47
Show Gist options
  • Save ezzabuzaid/1254c2bc74c13f18940c9256bd14ae27 to your computer and use it in GitHub Desktop.
Save ezzabuzaid/1254c2bc74c13f18940c9256bd14ae27 to your computer and use it in GitHub Desktop.
Migrate constructor injection to the new inject function.
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);
@ezzabuzaid
Copy link
Author

ezzabuzaid commented Apr 22, 2024

  • The script follows safe path of migration; only migrate what can be no guess or smart decision.
  • Due to complexity of migrating superclass I decided to ignore them.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment