Skip to content

Commit

Permalink
feat: add migration script for NgRx 8 creator functions
Browse files Browse the repository at this point in the history
  • Loading branch information
MKless authored and dhhyi committed Jun 15, 2020
1 parent e123b2a commit 5800cd2
Show file tree
Hide file tree
Showing 10 changed files with 882 additions and 102 deletions.
68 changes: 68 additions & 0 deletions schematics/migration/0.20-to-0.21/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
const { execSync, spawnSync } = require('child_process');
const { Project } = require('ts-morph');
const { Linter } = require('tslint');
const { readFileSync } = require('fs');
const glob = require('glob');

console.log('building lint rules');
execSync('npm run build:tslint-rules', { stdio: 'ignore' });
console.log('running sanity check');

const files = glob.sync('src/**/store/**/*.ts');

const linter = new Linter({
fix: true,
formatter: 'prose',
});
const lintConfig = {
rules: new Map()
.set('no-star-imports-in-store', true)
.set('force-jsdoc-comments', true)
.set('check-actions-for-creator-migration', true),
rulesDirectory: ['node_modules/intershop-tslint-rules'],
jsRules: new Map(),
extends: [],
};
files.forEach(sourcePath => {
linter.lint(sourcePath, readFileSync(sourcePath, { encoding: 'utf-8' }), lintConfig);
});

const result = linter.getResult();
if (result.errorCount) {
console.warn(result.output);
process.exit(1);
}

execSync('npx ts-node ' + process.argv[1] + '/store-migration.ts', { stdio: 'inherit' });

const changedFiles = () =>
spawnSync('git', ['--no-pager', 'diff', '--name-only'])
.stdout.toString('utf-8')
.split('\n')
.filter(x => !!x && x.endsWith('.ts') && (x.startsWith('src/app/') || x.startsWith('projects/')))
.sort();

const project = new Project({ tsConfigFilePath: 'tsconfig.all.json' });
changedFiles().forEach(path => {
console.log('post-processing', path);
const sf = project.getSourceFileOrThrow(path);
sf.fixMissingImports();
sf.fixUnusedIdentifiers();
sf.formatText({
indentSize: 2,
indentStyle: 2,
convertTabsToSpaces: true,
});
sf.saveSync();
execSync('node scripts/fix-imports ' + path);
try {
execSync('npx prettier --write ' + path);
} catch (err) {}
});

if (changedFiles().length) {
console.log('linting -- this will take some time');
execSync('npm run lint -- --fix --force');

execSync('npm run lint -- --fix', { stdio: 'inherit' });
}
263 changes: 263 additions & 0 deletions schematics/migration/0.20-to-0.21/migrate-action-creators.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import {
ClassDeclaration,
EnumDeclaration,
Node,
PropertyAccessExpression,
SourceFile,
SyntaxKind,
TypeAliasDeclaration,
TypeReferenceNode,
UnionTypeNode,
VariableDeclarationKind,
} from 'ts-morph';

// tslint:disable:no-console
export class ActionCreatorsActionsMorpher {
constructor(public actionsFile: SourceFile) {}
actionTypes: { [typeName: string]: string };

migrateActions() {
if (!this.actionsFile) {
return;
}
console.log('migrating', this.actionsFile.getFilePath());
if (!this.checkUnmigratedFile()) {
return;
}
console.log('replacing actions...');

this.actionsFile.addImportDeclaration({
moduleSpecifier: 'ish-core/utils/ngrx-creators',
namedImports: ['httpError', 'payload'],
});

this.readActionTypes();
this.replaceActions();
this.updateGlobalEnumReferences(this.actionsFile.getEnums()[0]);
const actionBundleType = this.actionsFile.getTypeAliases().find(al => /Actions?$/.test(al.getName()));
if (actionBundleType) {
this.updateGlobalTypeAliasReferences(actionBundleType);
}

// clean up old code
this.actionsFile.getEnums()[0].remove();

if (actionBundleType) {
actionBundleType.remove();
}
this.actionsFile.fixMissingImports();
}

/**
* read action types from actions enum and save in this.actionTypes
*/
private readActionTypes() {
console.log(' reading action types...');
this.actionTypes = this.actionsFile
.getEnums()[0]
.getMembers()
.reduce(
(acc, current) => ({
...acc,
[current.getName()]: current.getInitializer().getText(),
}),
{}
);
console.log(` ${Object.keys(this.actionTypes).length} actions found`);
}

/**
* replace action class declaration with createAction factory call
*/
private replaceActions() {
console.log(' replacing action classes with creator functions...');
this.actionsFile.getClasses().forEach(actionClass => {
// retrieve basic action information
const className = actionClass.getName();
const enumName = (actionClass.getPropertyOrThrow('type').getInitializer() as PropertyAccessExpression).getName();
const typeString = this.actionTypes[enumName];

// get parameter information
let initializer;
if (actionClass.getConstructors().length) {
const payloadParameter = actionClass.getConstructors()[0].getParameter('payload');
const payloadParameterTypeNode = payloadParameter.getTypeNode();
if (Node.isTypeLiteralNode(payloadParameterTypeNode)) {
const properties = payloadParameterTypeNode.getMembers().filter(Node.isPropertySignature);
if (properties.some(el => el.getName() === 'error' && el.getTypeNode().getText() === 'HttpError')) {
const params = properties
.filter(el => el.getName() !== 'error')
.map(el => `${el.getName()}: ${el.getTypeNode().getText()}`)
.join(', ');
if (params.length) {
initializer = `createAction(${typeString}, httpError<{${params}}>())`;
} else {
initializer = `createAction(${typeString}, httpError())`;
}
}
}

if (!initializer) {
initializer = `createAction(${typeString}, payload<${payloadParameter.getTypeNode().getText()}>())`;
}
}
if (!initializer) {
initializer = `createAction(${typeString})`;
}

// assemble structure object
const createActionStructure = {
isExported: true,
isDefaultExport: false,
hasDeclareKeyword: false,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: className.replace(/^\w/, c => c.toLowerCase()),
initializer,
type: undefined,
hasExclamationToken: false,
kind: 38,
},
],
};

this.actionsFile.addVariableStatement(createActionStructure);

// update references in other files
this.updateGlobalActionReferences(actionClass);
// fix updated files
actionClass
.findReferencesAsNodes()
.map(node => node.getSourceFile())
.filter((value, index, array) => index === array.indexOf(value))
.forEach(sf => {
sf.fixMissingImports();
});
// remove class from file
actionClass.remove();
});
}

/**
* replaces global references to a given actionClass with createAction calls
* @param actionClass the actionClass to update
*/
private updateGlobalActionReferences(actionClass: ClassDeclaration) {
console.log(` updating references for ${actionClass.getName()}...`);

// iterate over all actionClass references
let i = 0;
actionClass.findReferencesAsNodes().forEach(reference => {
// exclude tests and the actions file itself
if (reference.getSourceFile() !== this.actionsFile) {
// extract information about the reference
const newExpression = reference.getFirstAncestorByKind(SyntaxKind.NewExpression);
const unionType = reference.getFirstAncestorByKind(SyntaxKind.UnionType);
const callExpression = reference.getFirstAncestorByKind(SyntaxKind.CallExpression);

// NewExpressions or BinaryExpressions or CallExpressions
if (newExpression) {
// swap new class instantiation to actionCreator call
newExpression.replaceWithText(
actionClass.getName().substr(0, 1).toLowerCase() + newExpression.getText().substr(5)
);
i++;
return;
} else if (unionType) {
const typesArray = unionType.getTypeNodes().map(type => type.getText().replace(/^\w/, c => c.toLowerCase()));
const returnTypeString = `ReturnType <${typesArray.map(str => `typeof ${str}`).join(' | ')}>`;
unionType.replaceWithText(returnTypeString);
i++;
} else if (
callExpression &&
callExpression
.getArguments()
.filter(arg => arg.getKind() === SyntaxKind.Identifier)
.includes(reference)
) {
// update action references in call expressions
callExpression
.getArguments()
.filter(arg => arg === reference)
.forEach(arg => arg.replaceWithText(actionClass.getName().replace(/^\w/, c => c.toLowerCase())));
i++;
}

// ToDo: maybe update other expressions
}
});
i > 0 ? console.log(` updated ${i} reference${i > 1 ? 's' : ''}.`) : console.log(' no references found.');
actionClass.getSourceFile().fixMissingImports();
}

/**
* replaces global references to a given enumDeclaration
* @param enumDeclaration the enumDeclaration to update references of
*/
private updateGlobalEnumReferences(enumDeclaration: EnumDeclaration) {
console.log(' updating enum references...');
let i = 0;
enumDeclaration
.findReferencesAsNodes()
.filter(
ref =>
ref.getSourceFile() !== this.actionsFile &&
!ref.getSourceFile().getBaseName().includes('reducer.ts') &&
!ref.getSourceFile().getBaseName().includes('effects.ts') &&
ref.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) === undefined
)
.forEach(reference => {
const sibling = reference.getParentIfKind(SyntaxKind.PropertyAccessExpression)
? reference.getParent().getLastChild().getText()
: undefined;
if (sibling) {
reference.getParent().replaceWithText(`${sibling.replace(/^\w/, c => c.toLowerCase())}.type`);
i++;
}
});
console.log(` updated ${i} reference${i > 1 || i === 0 ? 's' : ''}`);
}

/**
* replaces global references to a given typeAlias
* @param typeAlias the typeAlias to update references of
*/
private updateGlobalTypeAliasReferences(typeAlias: TypeAliasDeclaration) {
console.log('updating type alias references...');
// extract types to string array
const types =
typeAlias.getTypeNode().getKind() === SyntaxKind.UnionType
? (typeAlias.getTypeNode() as UnionTypeNode)
.getTypeNodes()
.map(typeNode => typeNode.getText().replace(/^\w/, c => c.toLowerCase()))
: [
(typeAlias.getTypeNode() as TypeReferenceNode)
.getTypeName()
.getText()
.replace(/^\w/, c => c.toLowerCase()),
];
const typeString = `ReturnType< ${types.map(type => `typeof ${type}`).join(' | ')} >`;
typeAlias
.findReferencesAsNodes()
.filter(
ref =>
ref.getSourceFile() !== this.actionsFile &&
ref.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) === undefined
)
.forEach(reference => {
reference.replaceWithText(typeString);
});
}

private checkUnmigratedFile(): boolean {
const hasEnum = this.actionsFile.getEnums().length > 0;
const hasClass = this.actionsFile.getClasses().length > 0;
if (!hasEnum || !hasClass) {
console.log('this file is not a valid action file, skipping...');
return false;
} else {
return true;
}
}
}
Loading

0 comments on commit 5800cd2

Please sign in to comment.