feat(core): add undecorated classes migration schematic (#31650)

Introduces a new migration schematic that follows the given
migration plan: https://hackmd.io/@alx/S1XKqMZeS.

First case: The schematic detects decorated directives which
inherit a constructor. The migration ensures that all base
classes until the class with the explicit constructor are
properly decorated with "@Directive()" or "@Component". In
case one of these classes is not decorated, the schematic
adds the abstract "@Directive()" decorator automatically.

Second case: The schematic detects undecorated declarations
and copies the inherited "@Directive()", "@Component" or
"@Pipe" decorator to the undecorated derived class. This
involves non-trivial import rewriting, identifier aliasing
and AOT metadata serializing
(as decorators are not always part of source files)

PR Close #31650
This commit is contained in:
Paul Gschwendtner 2019-07-24 12:07:07 +02:00 committed by Kara Erickson
parent 5064dc75ac
commit 024c31da25
22 changed files with 3107 additions and 0 deletions

View File

@ -15,5 +15,6 @@ npm_package(
"//packages/core/schematics/migrations/renderer-to-renderer2",
"//packages/core/schematics/migrations/static-queries",
"//packages/core/schematics/migrations/template-var-assignment",
"//packages/core/schematics/migrations/undecorated-classes-with-di",
],
)

View File

@ -19,6 +19,11 @@
"version": "9-beta",
"description": "Migrates usages of Renderer to Renderer2",
"factory": "./migrations/renderer-to-renderer2/index"
},
"migration-v9-undecorated-classes-with-di": {
"version": "9-beta",
"description": "Decorates undecorated base classes of directives/components that use dependency injection. Copies metadata from base classes to derived directives/components/pipes that are not decorated.",
"factory": "./migrations/undecorated-classes-with-di/index"
}
}
}

View File

@ -0,0 +1,25 @@
load("//tools:defaults.bzl", "ts_library")
ts_library(
name = "undecorated-classes-with-di",
srcs = glob(["**/*.ts"]),
tsconfig = "//packages/core/schematics:tsconfig.json",
visibility = [
"//packages/core/schematics:__pkg__",
"//packages/core/schematics/migrations/undecorated-classes/google3:__pkg__",
"//packages/core/schematics/test:__pkg__",
],
deps = [
"//packages/compiler",
"//packages/compiler-cli",
"//packages/compiler-cli/src/ngtsc/imports",
"//packages/compiler-cli/src/ngtsc/partial_evaluator",
"//packages/compiler-cli/src/ngtsc/reflection",
"//packages/core",
"//packages/core/schematics/utils",
"@npm//@angular-devkit/core",
"@npm//@angular-devkit/schematics",
"@npm//@types/node",
"@npm//typescript",
],
)

View File

@ -0,0 +1,42 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AotCompiler, CompileStylesheetMetadata} from '@angular/compiler';
import {createProgram, readConfiguration} from '@angular/compiler-cli';
import * as ts from 'typescript';
/** Creates an NGC program that can be used to read and parse metadata for files. */
export function createNgcProgram(
createHost: (options: ts.CompilerOptions) => ts.CompilerHost, tsconfigPath: string | null,
parseConfig: () => {
rootNames: readonly string[],
options: ts.CompilerOptions
} = () => readConfiguration(tsconfigPath !)) {
const {rootNames, options} = parseConfig();
const host = createHost(options);
const ngcProgram = createProgram({rootNames, options, host});
const program = ngcProgram.getTsProgram();
// The "AngularCompilerProgram" does not expose the "AotCompiler" instance, nor does it
// expose the logic that is necessary to analyze the determined modules. We work around
// this by just accessing the necessary private properties using the bracket notation.
const compiler: AotCompiler = (ngcProgram as any)['compiler'];
const metadataResolver = compiler['_metadataResolver'];
// Modify the "DirectiveNormalizer" to not normalize any referenced external stylesheets.
// This is necessary because in CLI projects preprocessor files are commonly referenced
// and we don't want to parse them in order to extract relative style references. This
// breaks the analysis of the project because we instantiate a standalone AOT compiler
// program which does not contain the custom logic by the Angular CLI Webpack compiler plugin.
const directiveNormalizer = metadataResolver !['_directiveNormalizer'];
directiveNormalizer['_normalizeStylesheet'] = function(metadata: CompileStylesheetMetadata) {
return new CompileStylesheetMetadata(
{styles: metadata.styles, styleUrls: [], moduleUrl: metadata.moduleUrl !});
};
return {host, ngcProgram, program, compiler};
}

View File

@ -0,0 +1,76 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {StaticSymbol} from '@angular/compiler';
import * as ts from 'typescript';
/**
* Converts a directive metadata object into a TypeScript expression. Throws
* if metadata cannot be cleanly converted.
*/
export function convertDirectiveMetadataToExpression(
metadata: any, resolveSymbolImport: (symbol: StaticSymbol) => string | null,
createImport: (moduleName: string, name: string) => ts.Expression,
convertProperty?: (key: string, value: any) => ts.Expression | null): ts.Expression {
if (typeof metadata === 'string') {
return ts.createStringLiteral(metadata);
} else if (Array.isArray(metadata)) {
return ts.createArrayLiteral(metadata.map(
el => convertDirectiveMetadataToExpression(
el, resolveSymbolImport, createImport, convertProperty)));
} else if (typeof metadata === 'number') {
return ts.createNumericLiteral(metadata.toString());
} else if (typeof metadata === 'boolean') {
return metadata ? ts.createTrue() : ts.createFalse();
} else if (typeof metadata === 'undefined') {
return ts.createIdentifier('undefined');
} else if (typeof metadata === 'bigint') {
return ts.createBigIntLiteral(metadata.toString());
} else if (typeof metadata === 'object') {
// In case there is a static symbol object part of the metadata, try to resolve
// the import expression of the symbol. If no import path could be resolved, an
// error will be thrown as the symbol cannot be converted into TypeScript AST.
if (metadata instanceof StaticSymbol) {
const resolvedImport = resolveSymbolImport(metadata);
if (resolvedImport === null) {
throw new UnexpectedMetadataValueError();
}
return createImport(resolvedImport, metadata.name);
}
const literalProperties: ts.PropertyAssignment[] = [];
for (const key of Object.keys(metadata)) {
const metadataValue = metadata[key];
let propertyValue: ts.Expression|null = null;
// Allows custom conversion of properties in an object. This is useful for special
// cases where we don't want to store the enum values as integers, but rather use the
// real enum symbol. e.g. instead of `2` we want to use `ViewEncapsulation.None`.
if (convertProperty) {
propertyValue = convertProperty(key, metadataValue);
}
// In case the property value has not been assigned to an expression, we convert
// the resolved metadata value into a TypeScript expression.
if (propertyValue === null) {
propertyValue = convertDirectiveMetadataToExpression(
metadataValue, resolveSymbolImport, createImport, convertProperty);
}
literalProperties.push(ts.createPropertyAssignment(key, propertyValue));
}
return ts.createObjectLiteral(literalProperties, true);
}
throw new UnexpectedMetadataValueError();
}
/** Error that will be thrown if a unexpected value needs to be converted. */
export class UnexpectedMetadataValueError extends Error {}

View File

@ -0,0 +1,135 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AotCompiler} from '@angular/compiler';
import {PartialEvaluator} from '@angular/compiler-cli/src/ngtsc/partial_evaluator';
import * as ts from 'typescript';
import {NgDecorator} from '../../../utils/ng_decorators';
import {unwrapExpression} from '../../../utils/typescript/functions';
import {ImportManager} from '../import_manager';
import {ImportRewriteTransformerFactory, UnresolvedIdentifierError} from './import_rewrite_visitor';
/**
* Class that can be used to copy decorators to a new location. The rewriter ensures that
* identifiers and imports are rewritten to work in the new file location. Fields in a
* decorator that cannot be cleanly copied will be copied with a comment explaining that
* imports and identifiers need to be adjusted manually.
*/
export class DecoratorRewriter {
previousSourceFile: ts.SourceFile|null = null;
newSourceFile: ts.SourceFile|null = null;
newProperties: ts.ObjectLiteralElementLike[] = [];
nonCopyableProperties: ts.ObjectLiteralElementLike[] = [];
private importRewriterFactory = new ImportRewriteTransformerFactory(
this.importManager, this.typeChecker, this.compiler['_host']);
constructor(
private importManager: ImportManager, private typeChecker: ts.TypeChecker,
private evaluator: PartialEvaluator, private compiler: AotCompiler) {}
rewrite(ngDecorator: NgDecorator, newSourceFile: ts.SourceFile): ts.Decorator {
const decorator = ngDecorator.node;
// Reset the previous state of the decorator rewriter.
this.newProperties = [];
this.nonCopyableProperties = [];
this.newSourceFile = newSourceFile;
this.previousSourceFile = decorator.getSourceFile();
// If the decorator will be added to the same source file it currently
// exists in, we don't need to rewrite any paths or add new imports.
if (this.previousSourceFile === newSourceFile) {
return this._createDecorator(decorator.expression);
}
const oldCallExpr = decorator.expression;
if (!oldCallExpr.arguments.length) {
// Re-use the original decorator if there are no arguments and nothing needs
// to be sanitized or rewritten.
return this._createDecorator(decorator.expression);
}
const metadata = unwrapExpression(oldCallExpr.arguments[0]);
if (!ts.isObjectLiteralExpression(metadata)) {
// Re-use the original decorator as there is no metadata that can be sanitized.
return this._createDecorator(decorator.expression);
}
metadata.properties.forEach(prop => {
// We don't handle spread assignments, accessors or method declarations automatically
// as it involves more advanced static analysis and these type of properties are not
// picked up by ngc either.
if (ts.isSpreadAssignment(prop) || ts.isAccessor(prop) || ts.isMethodDeclaration(prop)) {
this.nonCopyableProperties.push(prop);
return;
}
const sanitizedProp = this._sanitizeMetadataProperty(prop);
if (sanitizedProp !== null) {
this.newProperties.push(sanitizedProp);
} else {
this.nonCopyableProperties.push(prop);
}
});
// In case there is at least one non-copyable property, we add a leading comment to
// the first property assignment in order to ask the developer to manually manage
// imports and do path rewriting for these properties.
if (this.nonCopyableProperties.length !== 0) {
['The following fields were copied from the base class,',
'but could not be updated automatically to work in the',
'new file location. Please add any required imports for', 'the properties below:']
.forEach(
text => ts.addSyntheticLeadingComment(
this.nonCopyableProperties[0], ts.SyntaxKind.SingleLineCommentTrivia, ` ${text}`,
true));
}
// Note that we don't update the decorator as we don't want to copy potential leading
// comments of the decorator. This is necessary because otherwise comments from the
// copied decorator end up describing the new class (which is not always correct).
return this._createDecorator(ts.createCall(
this.importManager.addImportToSourceFile(
newSourceFile, ngDecorator.name, ngDecorator.moduleName),
undefined, [ts.updateObjectLiteral(
metadata, [...this.newProperties, ...this.nonCopyableProperties])]));
}
/** Creates a new decorator with the given expression. */
private _createDecorator(expr: ts.Expression): ts.Decorator {
// Note that we don't update the decorator as we don't want to copy potential leading
// comments of the decorator. This is necessary because otherwise comments from the
// copied decorator end up describing the new class (which is not always correct).
return ts.createDecorator(expr);
}
/**
* Sanitizes a metadata property by ensuring that all contained identifiers
* are imported in the target source file.
*/
private _sanitizeMetadataProperty(prop: ts.ObjectLiteralElementLike): ts.ObjectLiteralElementLike
|null {
try {
return ts
.transform(prop, [ctx => this.importRewriterFactory.create(ctx, this.newSourceFile !)])
.transformed[0];
} catch (e) {
// If the error is for an unresolved identifier, we want to return "null" because
// such object literal elements could be added to the non-copyable properties.
if (e instanceof UnresolvedIdentifierError) {
return null;
}
throw e;
}
}
}

View File

@ -0,0 +1,142 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AotCompilerHost} from '@angular/compiler';
import {dirname, resolve} from 'path';
import * as ts from 'typescript';
import {Import, getImportOfIdentifier} from '../../../utils/typescript/imports';
import {getValueSymbolOfDeclaration} from '../../../utils/typescript/symbol';
import {ImportManager} from '../import_manager';
import {getPosixPath} from './path_format';
import {ResolvedExport, getExportSymbolsOfFile} from './source_file_exports';
/**
* Factory that creates a TypeScript transformer which ensures that
* referenced identifiers are available at the target file location.
*
* Imports cannot be just added as sometimes identifiers collide in the
* target source file and the identifier needs to be aliased.
*/
export class ImportRewriteTransformerFactory {
private sourceFileExports = new Map<ts.SourceFile, ResolvedExport[]>();
constructor(
private importManager: ImportManager, private typeChecker: ts.TypeChecker,
private compilerHost: AotCompilerHost) {}
create<T extends ts.Node>(ctx: ts.TransformationContext, newSourceFile: ts.SourceFile):
ts.Transformer<T> {
const visitNode: ts.Visitor = (node: ts.Node) => {
if (ts.isIdentifier(node)) {
// Record the identifier reference and return the new identifier. The identifier
// name can change if the generated import uses an namespaced import or aliased
// import identifier (to avoid collisions).
return this._recordIdentifierReference(node, newSourceFile);
}
return ts.visitEachChild(node, visitNode, ctx);
};
return (node: T) => ts.visitNode(node, visitNode);
}
private _recordIdentifierReference(node: ts.Identifier, targetSourceFile: ts.SourceFile):
ts.Node {
// For object literal elements we don't want to check identifiers that describe the
// property name. These identifiers do not refer to a value but rather to a property
// name and therefore don't need to be imported. The exception is that for shorthand
// property assignments the "name" identifier is both used as value and property name.
if (ts.isObjectLiteralElementLike(node.parent) &&
!ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node) {
return node;
}
const resolvedImport = getImportOfIdentifier(this.typeChecker, node);
const sourceFile = node.getSourceFile();
if (resolvedImport) {
const symbolName = resolvedImport.name;
const moduleFileName =
this.compilerHost.moduleNameToFileName(resolvedImport.importModule, sourceFile.fileName);
// In case the identifier refers to an export in the target source file, we need to use
// the local identifier in the scope of the target source file. This is necessary because
// the export could be aliased and the alias is not available to the target source file.
if (moduleFileName && resolve(moduleFileName) === resolve(targetSourceFile.fileName)) {
const resolvedExport =
this._getSourceFileExports(targetSourceFile).find(e => e.exportName === symbolName);
if (resolvedExport) {
return resolvedExport.identifier;
}
}
return this.importManager.addImportToSourceFile(
targetSourceFile, symbolName,
this._rewriteModuleImport(resolvedImport, targetSourceFile));
} else {
let symbol = getValueSymbolOfDeclaration(node, this.typeChecker);
if (symbol) {
// If the symbol refers to a shorthand property assignment, we want to resolve the
// value symbol of the shorthand property assignment. This is necessary because the
// value symbol is ambiguous for shorthand property assignment identifiers as the
// identifier resolves to both property name and property value.
if (symbol.valueDeclaration && ts.isShorthandPropertyAssignment(symbol.valueDeclaration)) {
symbol = this.typeChecker.getShorthandAssignmentValueSymbol(symbol.valueDeclaration);
}
const resolvedExport =
this._getSourceFileExports(sourceFile).find(e => e.symbol === symbol);
if (resolvedExport) {
return this.importManager.addImportToSourceFile(
targetSourceFile, resolvedExport.exportName,
getPosixPath(this.compilerHost.fileNameToModuleName(
sourceFile.fileName, targetSourceFile.fileName)));
}
}
// The referenced identifier cannot be imported. In that case we throw an exception
// which can be handled outside of the transformer.
throw new UnresolvedIdentifierError();
}
}
/**
* Gets the resolved exports of a given source file. Exports are cached
* for subsequent calls.
*/
private _getSourceFileExports(sourceFile: ts.SourceFile): ResolvedExport[] {
if (this.sourceFileExports.has(sourceFile)) {
return this.sourceFileExports.get(sourceFile) !;
}
const sourceFileExports = getExportSymbolsOfFile(sourceFile, this.typeChecker);
this.sourceFileExports.set(sourceFile, sourceFileExports);
return sourceFileExports;
}
/** Rewrites a module import to be relative to the target file location. */
private _rewriteModuleImport(resolvedImport: Import, newSourceFile: ts.SourceFile): string {
if (!resolvedImport.importModule.startsWith('.')) {
return resolvedImport.importModule;
}
const importFilePath = resolvedImport.node.getSourceFile().fileName;
const resolvedModulePath = resolve(dirname(importFilePath), resolvedImport.importModule);
const relativeModuleName =
this.compilerHost.fileNameToModuleName(resolvedModulePath, newSourceFile.fileName);
return getPosixPath(relativeModuleName);
}
}
/** Error that will be thrown if a given identifier cannot be resolved. */
export class UnresolvedIdentifierError extends Error {}

View File

@ -0,0 +1,18 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {normalize} from 'path';
/** Normalizes the specified path to conform with the posix path format. */
export function getPosixPath(pathString: string) {
const normalized = normalize(pathString).replace(/\\/g, '/');
if (!normalized.startsWith('.')) {
return `./${normalized}`;
}
return normalized;
}

View File

@ -0,0 +1,59 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import * as ts from 'typescript';
import {getValueSymbolOfDeclaration} from '../../../utils/typescript/symbol';
export interface ResolvedExport {
symbol: ts.Symbol;
exportName: string;
identifier: ts.Identifier;
}
/** Computes the resolved exports of a given source file. */
export function getExportSymbolsOfFile(
sf: ts.SourceFile, typeChecker: ts.TypeChecker): ResolvedExport[] {
const exports: {exportName: string, identifier: ts.Identifier}[] = [];
const resolvedExports: ResolvedExport[] = [];
ts.forEachChild(sf, function visitNode(node) {
if (ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node) ||
ts.isInterfaceDeclaration(node) &&
(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export) !== 0) {
if (node.name) {
exports.push({exportName: node.name.text, identifier: node.name});
}
} else if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
visitNode(decl);
}
} else if (ts.isVariableDeclaration(node)) {
if ((ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) != 0 &&
ts.isIdentifier(node.name)) {
exports.push({exportName: node.name.text, identifier: node.name});
}
} else if (ts.isExportDeclaration(node)) {
const {moduleSpecifier, exportClause} = node;
if (!moduleSpecifier && exportClause) {
exportClause.elements.forEach(el => exports.push({
exportName: el.name.text,
identifier: el.propertyName ? el.propertyName : el.name
}));
}
}
});
exports.forEach(({identifier, exportName}) => {
const symbol = getValueSymbolOfDeclaration(identifier, typeChecker);
if (symbol) {
resolvedExports.push({symbol, identifier, exportName});
}
});
return resolvedExports;
}

View File

@ -0,0 +1,30 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import * as ts from 'typescript';
import {getBaseTypeIdentifiers} from '../../utils/typescript/class_declaration';
/** Gets all base class declarations of the specified class declaration. */
export function findBaseClassDeclarations(node: ts.ClassDeclaration, typeChecker: ts.TypeChecker) {
const result: {identifier: ts.Identifier, node: ts.ClassDeclaration}[] = [];
let currentClass = node;
while (currentClass) {
const baseTypes = getBaseTypeIdentifiers(currentClass);
if (!baseTypes || baseTypes.length !== 1) {
break;
}
const symbol = typeChecker.getTypeAtLocation(baseTypes[0]).getSymbol();
if (!symbol || !ts.isClassDeclaration(symbol.valueDeclaration)) {
break;
}
result.push({identifier: baseTypes[0], node: symbol.valueDeclaration});
currentClass = symbol.valueDeclaration;
}
return result;
}

View File

@ -0,0 +1,254 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {dirname, resolve} from 'path';
import * as ts from 'typescript';
import {UpdateRecorder} from './update_recorder';
/**
* Import manager that can be used to add TypeScript imports to given source
* files. The manager ensures that multiple transformations are applied properly
* without shifted offsets and that similar existing import declarations are re-used.
*/
export class ImportManager {
/** Map of import declarations that need to be updated to include the given symbols. */
private updatedImports =
new Map<ts.ImportDeclaration, {propertyName?: ts.Identifier, importName: ts.Identifier}[]>();
/** Map of source-files and their previously used identifier names. */
private usedIdentifierNames = new Map<ts.SourceFile, string[]>();
/**
* Array of previously resolved symbol imports. Cache can be re-used to return
* the same identifier without checking the source-file again.
*/
private importCache: {
sourceFile: ts.SourceFile,
symbolName: string|null,
moduleName: string,
identifier: ts.Identifier
}[] = [];
constructor(
private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder,
private printer: ts.Printer) {}
/**
* Adds an import to the given source-file and returns the TypeScript
* identifier that can be used to access the newly imported symbol.
*/
addImportToSourceFile(
sourceFile: ts.SourceFile, symbolName: string|null, moduleName: string,
typeImport = false): ts.Expression {
const sourceDir = dirname(sourceFile.fileName);
let importStartIndex = 0;
let existingImport: ts.ImportDeclaration|null = null;
// In case the given import has been already generated previously, we just return
// the previous generated identifier in order to avoid duplicate generated imports.
const cachedImport = this.importCache.find(
c => c.sourceFile === sourceFile && c.symbolName === symbolName &&
c.moduleName === moduleName);
if (cachedImport) {
return cachedImport.identifier;
}
// Walk through all source-file top-level statements and search for import declarations
// that already match the specified "moduleName" and can be updated to import the
// given symbol. If no matching import can be found, the last import in the source-file
// will be used as starting point for a new import that will be generated.
for (let i = sourceFile.statements.length - 1; i >= 0; i--) {
const statement = sourceFile.statements[i];
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) ||
!statement.importClause) {
continue;
}
if (importStartIndex === 0) {
importStartIndex = this._getEndPositionOfNode(statement);
}
const moduleSpecifier = statement.moduleSpecifier.text;
if (moduleSpecifier.startsWith('.') &&
resolve(sourceDir, moduleSpecifier) !== resolve(sourceDir, moduleName) ||
moduleSpecifier !== moduleName) {
continue;
}
if (statement.importClause.namedBindings) {
const namedBindings = statement.importClause.namedBindings;
// In case a "Type" symbol is imported, we can't use namespace imports
// because these only export symbols available at runtime (no types)
if (ts.isNamespaceImport(namedBindings) && !typeImport) {
return ts.createPropertyAccess(
ts.createIdentifier(namedBindings.name.text),
ts.createIdentifier(symbolName || 'default'));
} else if (ts.isNamedImports(namedBindings) && symbolName) {
const existingElement = namedBindings.elements.find(
e =>
e.propertyName ? e.propertyName.text === symbolName : e.name.text === symbolName);
if (existingElement) {
return ts.createIdentifier(existingElement.name.text);
}
// In case the symbol could not be found in an existing import, we
// keep track of the import declaration as it can be updated to include
// the specified symbol name without having to create a new import.
existingImport = statement;
}
} else if (statement.importClause.name && !symbolName) {
return ts.createIdentifier(statement.importClause.name.text);
}
}
if (existingImport) {
const propertyIdentifier = ts.createIdentifier(symbolName !);
const generatedUniqueIdentifier = this._getUniqueIdentifier(sourceFile, symbolName !);
const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName;
const importName = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier;
// Since it can happen that multiple classes need to be imported within the
// specified source file and we want to add the identifiers to the existing
// import declaration, we need to keep track of the updated import declarations.
// We can't directly update the import declaration for each identifier as this
// would throw off the recorder offsets. We need to keep track of the new identifiers
// for the import and perform the import transformation as batches per source-file.
this.updatedImports.set(
existingImport, (this.updatedImports.get(existingImport) || []).concat({
propertyName: needsGeneratedUniqueName ? propertyIdentifier : undefined,
importName: importName,
}));
// Keep track of all updated imports so that we don't generate duplicate
// similar imports as these can't be statically analyzed in the source-file yet.
this.importCache.push({sourceFile, moduleName, symbolName, identifier: importName});
return importName;
}
let identifier: ts.Identifier|null = null;
let newImport: ts.ImportDeclaration|null = null;
if (symbolName) {
const propertyIdentifier = ts.createIdentifier(symbolName);
const generatedUniqueIdentifier = this._getUniqueIdentifier(sourceFile, symbolName);
const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName;
identifier = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier;
newImport = ts.createImportDeclaration(
undefined, undefined,
ts.createImportClause(
undefined,
ts.createNamedImports([ts.createImportSpecifier(
needsGeneratedUniqueName ? propertyIdentifier : undefined, identifier)])),
ts.createStringLiteral(moduleName));
} else {
identifier = this._getUniqueIdentifier(sourceFile, 'defaultExport');
newImport = ts.createImportDeclaration(
undefined, undefined, ts.createImportClause(identifier, undefined),
ts.createStringLiteral(moduleName));
}
const newImportText = this.printer.printNode(ts.EmitHint.Unspecified, newImport, sourceFile);
// If the import is generated at the start of the source file, we want to add
// a new-line after the import. Otherwise if the import is generated after an
// existing import, we need to prepend a new-line so that the import is not on
// the same line as the existing import anchor.
this.getUpdateRecorder(sourceFile)
.addNewImport(
importStartIndex, importStartIndex === 0 ? `${newImportText}\n` : `\n${newImportText}`);
// Keep track of all generated imports so that we don't generate duplicate
// similar imports as these can't be statically analyzed in the source-file yet.
this.importCache.push({sourceFile, symbolName, moduleName, identifier});
return identifier;
}
/**
* Stores the collected import changes within the appropriate update recorders. The
* updated imports can only be updated *once* per source-file because previous updates
* could otherwise shift the source-file offsets.
*/
recordChanges() {
this.updatedImports.forEach((expressions, importDecl) => {
const sourceFile = importDecl.getSourceFile();
const recorder = this.getUpdateRecorder(sourceFile);
const namedBindings = importDecl.importClause !.namedBindings as ts.NamedImports;
const newNamedBindings = ts.updateNamedImports(
namedBindings,
namedBindings.elements.concat(expressions.map(
({propertyName, importName}) => ts.createImportSpecifier(propertyName, importName))));
const newNamedBindingsText =
this.printer.printNode(ts.EmitHint.Unspecified, newNamedBindings, sourceFile);
recorder.updateExistingImport(namedBindings, newNamedBindingsText);
});
}
/** Gets an unique identifier with a base name for the given source file. */
private _getUniqueIdentifier(sourceFile: ts.SourceFile, baseName: string): ts.Identifier {
if (this.isUniqueIdentifierName(sourceFile, baseName)) {
this._recordUsedIdentifier(sourceFile, baseName);
return ts.createIdentifier(baseName);
}
let name = null;
let counter = 1;
do {
name = `${baseName}_${counter++}`;
} while (!this.isUniqueIdentifierName(sourceFile, name));
this._recordUsedIdentifier(sourceFile, name !);
return ts.createIdentifier(name !);
}
/**
* Checks whether the specified identifier name is used within the given
* source file.
*/
private isUniqueIdentifierName(sourceFile: ts.SourceFile, name: string) {
if (this.usedIdentifierNames.has(sourceFile) &&
this.usedIdentifierNames.get(sourceFile) !.indexOf(name) !== -1) {
return false;
}
// Walk through the source file and search for an identifier matching
// the given name. In that case, it's not guaranteed that this name
// is unique in the given declaration scope and we just return false.
const nodeQueue: ts.Node[] = [sourceFile];
while (nodeQueue.length) {
const node = nodeQueue.shift() !;
if (ts.isIdentifier(node) && node.text === name) {
return false;
}
nodeQueue.push(...node.getChildren());
}
return true;
}
private _recordUsedIdentifier(sourceFile: ts.SourceFile, identifierName: string) {
this.usedIdentifierNames.set(
sourceFile, (this.usedIdentifierNames.get(sourceFile) || []).concat(identifierName));
}
/**
* Determines the full end of a given node. By default the end position of a node is
* before all trailing comments. This could mean that generated imports shift comments.
*/
private _getEndPositionOfNode(node: ts.Node) {
const nodeEndPos = node.getEnd();
const commentRanges = ts.getTrailingCommentRanges(node.getSourceFile().text, nodeEndPos);
if (!commentRanges || !commentRanges.length) {
return nodeEndPos;
}
return commentRanges[commentRanges.length - 1] !.end;
}
}

View File

@ -0,0 +1,190 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {logging} from '@angular-devkit/core';
import {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics';
import {AotCompiler} from '@angular/compiler';
import {PartialEvaluator} from '@angular/compiler-cli/src/ngtsc/partial_evaluator';
import {TypeScriptReflectionHost} from '@angular/compiler-cli/src/ngtsc/reflection';
import {relative} from 'path';
import * as ts from 'typescript';
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
import {createNgcProgram} from './create_ngc_program';
import {NgDeclarationCollector} from './ng_declaration_collector';
import {UndecoratedClassesTransform} from './transform';
import {UpdateRecorder} from './update_recorder';
const MIGRATION_RERUN_MESSAGE = 'Migration can be rerun with: "ng update @angular/core ' +
'--from 8.0.0 --to 9.0.0 --migrate-only"';
const MIGRATION_AOT_FAILURE = 'This migration uses the Angular compiler internally and ' +
'therefore projects that no longer build successfully after the update cannot run ' +
'the migration. Please ensure there are no AOT compilation errors and rerun the migration.';
/** Entry point for the V9 "undecorated-classes-with-di" migration. */
export default function(): Rule {
return (tree: Tree, ctx: SchematicContext) => {
const {buildPaths} = getProjectTsConfigPaths(tree);
const basePath = process.cwd();
const failures: string[] = [];
ctx.logger.info('------ Undecorated classes with DI migration ------');
if (!buildPaths.length) {
throw new SchematicsException(
'Could not find any tsconfig file. Cannot migrate undecorated derived classes and ' +
'undecorated base classes which use DI.');
}
for (const tsconfigPath of buildPaths) {
failures.push(...runUndecoratedClassesMigration(tree, tsconfigPath, basePath, ctx.logger));
}
if (failures.length) {
ctx.logger.info('Could not migrate all undecorated classes that use dependency');
ctx.logger.info('injection. Please manually fix the following failures:');
failures.forEach(message => ctx.logger.warn(`${message}`));
} else {
ctx.logger.info('Successfully migrated all found undecorated classes');
ctx.logger.info('that use dependency injection.');
}
ctx.logger.info('----------------------------------------------');
};
}
function runUndecoratedClassesMigration(
tree: Tree, tsconfigPath: string, basePath: string, logger: logging.LoggerApi): string[] {
const failures: string[] = [];
const programData = gracefullyCreateProgram(tree, basePath, tsconfigPath, logger);
// Gracefully exit if the program could not be created.
if (programData === null) {
return [];
}
const {program, compiler} = programData;
const typeChecker = program.getTypeChecker();
const partialEvaluator =
new PartialEvaluator(new TypeScriptReflectionHost(typeChecker), typeChecker);
const declarationCollector = new NgDeclarationCollector(typeChecker, partialEvaluator);
const rootSourceFiles = program.getRootFileNames().map(f => program.getSourceFile(f) !);
// Analyze source files by detecting all directives, components and providers.
rootSourceFiles.forEach(sourceFile => declarationCollector.visitNode(sourceFile));
const {decoratedDirectives, decoratedProviders, undecoratedDeclarations} = declarationCollector;
const transform =
new UndecoratedClassesTransform(typeChecker, compiler, partialEvaluator, getUpdateRecorder);
const updateRecorders = new Map<ts.SourceFile, UpdateRecorder>();
// Run the migrations for decorated providers and both decorated and undecorated
// directives. The transform failures are collected and converted into human-readable
// failures which can be printed to the console.
[...transform.migrateDecoratedDirectives(decoratedDirectives),
...transform.migrateDecoratedProviders(decoratedProviders),
...transform.migrateUndecoratedDeclarations(Array.from(undecoratedDeclarations))]
.forEach(({node, message}) => {
const nodeSourceFile = node.getSourceFile();
const relativeFilePath = relative(basePath, nodeSourceFile.fileName);
const {line, character} =
ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart());
failures.push(`${relativeFilePath}@${line + 1}:${character + 1}: ${message}`);
});
// Record the changes collected in the import manager and transformer.
transform.recordChanges();
// Walk through each update recorder and commit the update. We need to commit the
// updates in batches per source file as there can be only one recorder per source
// file in order to avoid shifted character offsets.
updateRecorders.forEach(recorder => recorder.commitUpdate());
return failures;
/** Gets the update recorder for the specified source file. */
function getUpdateRecorder(sourceFile: ts.SourceFile): UpdateRecorder {
if (updateRecorders.has(sourceFile)) {
return updateRecorders.get(sourceFile) !;
}
const treeRecorder = tree.beginUpdate(relative(basePath, sourceFile.fileName));
const recorder: UpdateRecorder = {
addClassComment(node: ts.ClassDeclaration, text: string) {
treeRecorder.insertLeft(node.members.pos, `\n // ${text}\n`);
},
addClassDecorator(node: ts.ClassDeclaration, text: string) {
// New imports should be inserted at the left while decorators should be inserted
// at the right in order to ensure that imports are inserted before the decorator
// if the start position of import and decorator is the source file start.
treeRecorder.insertRight(node.getStart(), `${text}\n`);
},
addNewImport(start: number, importText: string) {
// New imports should be inserted at the left while decorators should be inserted
// at the right in order to ensure that imports are inserted before the decorator
// if the start position of import and decorator is the source file start.
treeRecorder.insertLeft(start, importText);
},
updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string) {
treeRecorder.remove(namedBindings.getStart(), namedBindings.getWidth());
treeRecorder.insertRight(namedBindings.getStart(), newNamedBindings);
},
commitUpdate() { tree.commitUpdate(treeRecorder); }
};
updateRecorders.set(sourceFile, recorder);
return recorder;
}
}
function gracefullyCreateProgram(
tree: Tree, basePath: string, tsconfigPath: string,
logger: logging.LoggerApi): {compiler: AotCompiler, program: ts.Program}|null {
try {
const {ngcProgram, host, program, compiler} = createNgcProgram((options) => {
const host = ts.createCompilerHost(options, true);
// We need to overwrite the host "readFile" method, as we want the TypeScript
// program to be based on the file contents in the virtual file tree.
host.readFile = fileName => {
const buffer = tree.read(relative(basePath, fileName));
// Strip BOM as otherwise TSC methods (Ex: getWidth) will return an offset which
// which breaks the CLI UpdateRecorder.
// See: https://github.com/angular/angular/pull/30719
return buffer ? buffer.toString().replace(/^\uFEFF/, '') : undefined;
};
return host;
}, tsconfigPath);
const syntacticDiagnostics = ngcProgram.getTsSyntacticDiagnostics();
const structuralDiagnostics = ngcProgram.getNgStructuralDiagnostics();
// Syntactic TypeScript errors can throw off the query analysis and therefore we want
// to notify the developer that we couldn't analyze parts of the project. Developers
// can just re-run the migration after fixing these failures.
if (syntacticDiagnostics.length) {
logger.warn(
`\nTypeScript project "${tsconfigPath}" has syntactical errors which could cause ` +
`an incomplete migration. Please fix the following failures and rerun the migration:`);
logger.error(ts.formatDiagnostics(syntacticDiagnostics, host));
logger.info(MIGRATION_RERUN_MESSAGE);
return null;
}
if (structuralDiagnostics.length) {
throw new Error(ts.formatDiagnostics(<ts.Diagnostic[]>structuralDiagnostics, host));
}
return {program, compiler};
} catch (e) {
logger.warn(`\n${MIGRATION_AOT_FAILURE}. The following project failed: ${tsconfigPath}\n`);
logger.error(`${e.toString()}\n`);
logger.info(MIGRATION_RERUN_MESSAGE);
return null;
}
}

View File

@ -0,0 +1,147 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Reference} from '@angular/compiler-cli/src/ngtsc/imports';
import {PartialEvaluator, ResolvedValue} from '@angular/compiler-cli/src/ngtsc/partial_evaluator';
import * as ts from 'typescript';
import {NgDecorator, getAngularDecorators} from '../../utils/ng_decorators';
import {getPropertyNameText} from '../../utils/typescript/property_name';
/**
* Visitor that walks through specified TypeScript nodes and collects all defined
* directives and provider classes. Directives are separated by decorated and
* undecorated directives.
*/
export class NgDeclarationCollector {
/** List of resolved directives which are decorated. */
decoratedDirectives: ts.ClassDeclaration[] = [];
/** List of resolved providers which are decorated. */
decoratedProviders: ts.ClassDeclaration[] = [];
/** Set of resolved Angular declarations which are not decorated. */
undecoratedDeclarations = new Set<ts.ClassDeclaration>();
constructor(public typeChecker: ts.TypeChecker, private evaluator: PartialEvaluator) {}
visitNode(node: ts.Node) {
if (ts.isClassDeclaration(node)) {
this._visitClassDeclaration(node);
}
ts.forEachChild(node, n => this.visitNode(n));
}
private _visitClassDeclaration(node: ts.ClassDeclaration) {
if (!node.decorators || !node.decorators.length) {
return;
}
const ngDecorators = getAngularDecorators(this.typeChecker, node.decorators);
const ngModuleDecorator = ngDecorators.find(({name}) => name === 'NgModule');
if (hasDirectiveDecorator(node, this.typeChecker, ngDecorators)) {
this.decoratedDirectives.push(node);
} else if (hasInjectableDecorator(node, this.typeChecker, ngDecorators)) {
this.decoratedProviders.push(node);
} else if (ngModuleDecorator) {
this._visitNgModuleDecorator(ngModuleDecorator);
}
}
private _visitNgModuleDecorator(decorator: NgDecorator) {
const decoratorCall = decorator.node.expression;
const metadata = decoratorCall.arguments[0];
if (!metadata || !ts.isObjectLiteralExpression(metadata)) {
return;
}
let entryComponentsNode: ts.Expression|null = null;
let declarationsNode: ts.Expression|null = null;
metadata.properties.forEach(p => {
if (!ts.isPropertyAssignment(p)) {
return;
}
const name = getPropertyNameText(p.name);
if (name === 'entryComponents') {
entryComponentsNode = p.initializer;
} else if (name === 'declarations') {
declarationsNode = p.initializer;
}
});
// In case the module specifies the "entryComponents" field, walk through all
// resolved entry components and collect the referenced directives.
if (entryComponentsNode) {
flattenTypeList(this.evaluator.evaluate(entryComponentsNode)).forEach(ref => {
if (ts.isClassDeclaration(ref.node) &&
!hasNgDeclarationDecorator(ref.node, this.typeChecker)) {
this.undecoratedDeclarations.add(ref.node);
}
});
}
// In case the module specifies the "declarations" field, walk through all
// resolved declarations and collect the referenced directives.
if (declarationsNode) {
flattenTypeList(this.evaluator.evaluate(declarationsNode)).forEach(ref => {
if (ts.isClassDeclaration(ref.node) &&
!hasNgDeclarationDecorator(ref.node, this.typeChecker)) {
this.undecoratedDeclarations.add(ref.node);
}
});
}
}
}
/** Flattens a list of type references. */
function flattenTypeList(value: ResolvedValue): Reference[] {
if (Array.isArray(value)) {
return <Reference[]>value.reduce(
(res: Reference[], v: ResolvedValue) => res.concat(flattenTypeList(v)), []);
} else if (value instanceof Reference) {
return [value];
}
return [];
}
/** Checks whether the given node has the "@Directive" or "@Component" decorator set. */
export function hasDirectiveDecorator(
node: ts.ClassDeclaration, typeChecker: ts.TypeChecker, ngDecorators?: NgDecorator[]): boolean {
return (ngDecorators || getNgClassDecorators(node, typeChecker))
.some(({name}) => name === 'Directive' || name === 'Component');
}
/** Checks whether the given node has the "@Injectable" decorator set. */
export function hasInjectableDecorator(
node: ts.ClassDeclaration, typeChecker: ts.TypeChecker, ngDecorators?: NgDecorator[]): boolean {
return (ngDecorators || getNgClassDecorators(node, typeChecker))
.some(({name}) => name === 'Injectable');
}
/** Whether the given node has an explicit decorator that describes an Angular declaration. */
export function hasNgDeclarationDecorator(node: ts.ClassDeclaration, typeChecker: ts.TypeChecker) {
return getNgClassDecorators(node, typeChecker)
.some(({name}) => name === 'Component' || name === 'Directive' || name === 'Pipe');
}
/** Gets all Angular decorators of a given class declaration. */
export function getNgClassDecorators(
node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): NgDecorator[] {
if (!node.decorators) {
return [];
}
return getAngularDecorators(typeChecker, node.decorators);
}

View File

@ -0,0 +1,491 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AotCompiler, AotCompilerHost, CompileMetadataResolver, StaticSymbol, StaticSymbolResolver, SummaryResolver} from '@angular/compiler';
import {PartialEvaluator} from '@angular/compiler-cli/src/ngtsc/partial_evaluator';
import {ChangeDetectionStrategy, ViewEncapsulation} from '@angular/core';
import * as ts from 'typescript';
import {getAngularDecorators} from '../../utils/ng_decorators';
import {hasExplicitConstructor} from '../../utils/typescript/class_declaration';
import {getImportOfIdentifier} from '../../utils/typescript/imports';
import {UnexpectedMetadataValueError, convertDirectiveMetadataToExpression} from './decorator_rewrite/convert_directive_metadata';
import {DecoratorRewriter} from './decorator_rewrite/decorator_rewriter';
import {findBaseClassDeclarations} from './find_base_classes';
import {ImportManager} from './import_manager';
import {hasDirectiveDecorator, hasInjectableDecorator} from './ng_declaration_collector';
import {UpdateRecorder} from './update_recorder';
/** Resolved metadata of a declaration. */
interface DeclarationMetadata {
metadata: any;
type: 'Component'|'Directive'|'Pipe';
}
export interface TransformFailure {
node: ts.Node;
message: string;
}
export class UndecoratedClassesTransform {
private printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed});
private importManager = new ImportManager(this.getUpdateRecorder, this.printer);
private decoratorRewriter =
new DecoratorRewriter(this.importManager, this.typeChecker, this.evaluator, this.compiler);
private compilerHost: AotCompilerHost;
private symbolResolver: StaticSymbolResolver;
private metadataResolver: CompileMetadataResolver;
/** Set of class declarations which have been decorated with "@Directive". */
private decoratedDirectives = new Set<ts.ClassDeclaration>();
/** Set of class declarations which have been decorated with "@Injectable" */
private decoratedProviders = new Set<ts.ClassDeclaration>();
/**
* Set of class declarations which have been analyzed and need to specify
* an explicit constructor.
*/
private missingExplicitConstructorClasses = new Set<ts.ClassDeclaration>();
constructor(
private typeChecker: ts.TypeChecker, private compiler: AotCompiler,
private evaluator: PartialEvaluator,
private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder) {
this.symbolResolver = compiler['_symbolResolver'];
this.compilerHost = compiler['_host'];
this.metadataResolver = compiler['_metadataResolver'];
// Unset the default error recorder so that the reflector will throw an exception
// if metadata cannot be resolved.
this.compiler.reflector['errorRecorder'] = undefined;
// Disables that static symbols are resolved through summaries from within the static
// reflector. Summaries cannot be used for decorator serialization as decorators are
// omitted in summaries and the decorator can't be reconstructed from the directive summary.
this._disableSummaryResolution();
}
/**
* Migrates decorated directives which can potentially inherit a constructor
* from an undecorated base class. All base classes until the first one
* with an explicit constructor will be decorated with the abstract "@Directive()"
* decorator. See case 1 in the migration plan: https://hackmd.io/@alx/S1XKqMZeS
*/
migrateDecoratedDirectives(directives: ts.ClassDeclaration[]): TransformFailure[] {
return directives.reduce(
(failures, node) => failures.concat(this._migrateDirectiveBaseClass(node)),
[] as TransformFailure[]);
}
/**
* Migrates decorated providers which can potentially inherit a constructor
* from an undecorated base class. All base classes until the first one
* with an explicit constructor will be decorated with the "@Injectable()".
*/
migrateDecoratedProviders(providers: ts.ClassDeclaration[]): TransformFailure[] {
return providers.reduce(
(failures, node) => failures.concat(this._migrateProviderBaseClass(node)),
[] as TransformFailure[]);
}
private _migrateProviderBaseClass(node: ts.ClassDeclaration): TransformFailure[] {
// In case the provider has an explicit constructor, we don't need to do anything
// because the class is already decorated and does not inherit a constructor.
if (hasExplicitConstructor(node)) {
return [];
}
const orderedBaseClasses = findBaseClassDeclarations(node, this.typeChecker);
let lastDecoratedClass: ts.ClassDeclaration = node;
for (let {node: baseClass, identifier} of orderedBaseClasses) {
const baseClassFile = baseClass.getSourceFile();
if (hasExplicitConstructor(baseClass)) {
if (baseClassFile.isDeclarationFile) {
const staticSymbol = this._getStaticSymbolOfIdentifier(identifier);
// If the base class is decorated through metadata files, we don't
// need to add a comment to the derived class for the external base class.
if (staticSymbol && this.metadataResolver.isInjectable(staticSymbol)) {
break;
}
// If the base class is not decorated, we cannot decorate the base class and
// need to a comment to the last decorated class.
return this._addMissingExplicitConstructorTodo(lastDecoratedClass);
}
this._addInjectableDecorator(baseClass);
break;
}
// Add the "@Injectable" decorator for all base classes in the inheritance chain
// until the base class with the explicit constructor. The decorator will be only
// added for base classes which can be modified.
if (!baseClassFile.isDeclarationFile) {
this._addInjectableDecorator(baseClass);
lastDecoratedClass = baseClass;
}
}
return [];
}
private _migrateDirectiveBaseClass(node: ts.ClassDeclaration): TransformFailure[] {
// In case the directive has an explicit constructor, we don't need to do
// anything because the class is already decorated with "@Directive" or "@Component"
if (hasExplicitConstructor(node)) {
return [];
}
const orderedBaseClasses = findBaseClassDeclarations(node, this.typeChecker);
let lastDecoratedClass: ts.ClassDeclaration = node;
for (let {node: baseClass, identifier} of orderedBaseClasses) {
const baseClassFile = baseClass.getSourceFile();
if (hasExplicitConstructor(baseClass)) {
if (baseClassFile.isDeclarationFile) {
// If the base class is decorated through metadata files, we don't
// need to add a comment to the derived class for the external base class.
if (this._hasDirectiveMetadata(identifier)) {
break;
}
// If the base class is not decorated, we cannot decorate the base class and
// need to a comment to the last decorated class.
return this._addMissingExplicitConstructorTodo(lastDecoratedClass);
}
this._addAbstractDirectiveDecorator(baseClass);
break;
}
// Add the abstract directive decorator for all base classes in the inheritance
// chain until the base class with the explicit constructor. The decorator will
// be only added for base classes which can be modified.
if (!baseClassFile.isDeclarationFile) {
this._addAbstractDirectiveDecorator(baseClass);
lastDecoratedClass = baseClass;
}
}
return [];
}
/**
* Adds the abstract "@Directive()" decorator to the given class in case there
* is no existing directive decorator.
*/
private _addAbstractDirectiveDecorator(baseClass: ts.ClassDeclaration) {
if (hasDirectiveDecorator(baseClass, this.typeChecker) ||
this.decoratedDirectives.has(baseClass)) {
return;
}
const baseClassFile = baseClass.getSourceFile();
const recorder = this.getUpdateRecorder(baseClassFile);
const directiveExpr =
this.importManager.addImportToSourceFile(baseClassFile, 'Directive', '@angular/core');
const newDecorator = ts.createDecorator(ts.createCall(directiveExpr, undefined, []));
const newDecoratorText =
this.printer.printNode(ts.EmitHint.Unspecified, newDecorator, baseClassFile);
recorder.addClassDecorator(baseClass, newDecoratorText);
this.decoratedDirectives.add(baseClass);
}
/**
* Adds the abstract "@Injectable()" decorator to the given class in case there
* is no existing directive decorator.
*/
private _addInjectableDecorator(baseClass: ts.ClassDeclaration) {
if (hasInjectableDecorator(baseClass, this.typeChecker) ||
this.decoratedProviders.has(baseClass)) {
return;
}
const baseClassFile = baseClass.getSourceFile();
const recorder = this.getUpdateRecorder(baseClassFile);
const injectableExpr =
this.importManager.addImportToSourceFile(baseClassFile, 'Injectable', '@angular/core');
const newDecorator = ts.createDecorator(ts.createCall(injectableExpr, undefined, []));
const newDecoratorText =
this.printer.printNode(ts.EmitHint.Unspecified, newDecorator, baseClassFile);
recorder.addClassDecorator(baseClass, newDecoratorText);
this.decoratedProviders.add(baseClass);
}
/** Adds a comment for adding an explicit constructor to the given class declaration. */
private _addMissingExplicitConstructorTodo(node: ts.ClassDeclaration): TransformFailure[] {
// In case a todo comment has been already inserted to the given class, we don't
// want to add a comment or transform failure multiple times.
if (this.missingExplicitConstructorClasses.has(node)) {
return [];
}
this.missingExplicitConstructorClasses.add(node);
const recorder = this.getUpdateRecorder(node.getSourceFile());
recorder.addClassComment(node, 'TODO: add explicit constructor');
return [{node: node, message: 'Class needs to declare an explicit constructor.'}];
}
/**
* Migrates undecorated directives which were referenced in NgModule declarations.
* These directives inherit the metadata from a parent base class, but with Ivy
* these classes need to explicitly have a decorator for locality. The migration
* determines the inherited decorator and copies it to the undecorated declaration.
*
* Note that the migration serializes the metadata for external declarations
* where the decorator is not part of the source file AST.
*
* See case 2 in the migration plan: https://hackmd.io/@alx/S1XKqMZeS
*/
migrateUndecoratedDeclarations(directives: ts.ClassDeclaration[]): TransformFailure[] {
return directives.reduce(
(failures, node) => failures.concat(this._migrateDerivedDeclaration(node)),
[] as TransformFailure[]);
}
private _migrateDerivedDeclaration(node: ts.ClassDeclaration): TransformFailure[] {
const targetSourceFile = node.getSourceFile();
const orderedBaseClasses = findBaseClassDeclarations(node, this.typeChecker);
let newDecoratorText: string|null = null;
for (let {node: baseClass, identifier} of orderedBaseClasses) {
// Before looking for decorators within the metadata or summary files, we
// try to determine the directive decorator through the source file AST.
if (baseClass.decorators) {
const ngDecorator =
getAngularDecorators(this.typeChecker, baseClass.decorators)
.find(({name}) => name === 'Component' || name === 'Directive' || name === 'Pipe');
if (ngDecorator) {
const newDecorator = this.decoratorRewriter.rewrite(ngDecorator, node.getSourceFile());
newDecoratorText = this.printer.printNode(
ts.EmitHint.Unspecified, newDecorator, ngDecorator.node.getSourceFile());
break;
}
}
// If no metadata could be found within the source-file AST, try to find
// decorator data through Angular metadata and summary files.
const staticSymbol = this._getStaticSymbolOfIdentifier(identifier);
// Check if the static symbol resolves to a class declaration with
// pipe or directive metadata.
if (!staticSymbol ||
!(this.metadataResolver.isPipe(staticSymbol) ||
this.metadataResolver.isDirective(staticSymbol))) {
continue;
}
const metadata = this._resolveDeclarationMetadata(staticSymbol);
// If no metadata could be resolved for the static symbol, print a failure message
// and ask the developer to manually migrate the class. This case is rare because
// usually decorator metadata is always present but just can't be read if a program
// only has access to summaries (this is a special case in google3).
if (!metadata) {
return [{
node,
message: `Class cannot be migrated as the inherited metadata from ` +
`${identifier.getText()} cannot be converted into a decorator. Please manually
decorate the class.`,
}];
}
const newDecorator = this._constructDecoratorFromMetadata(metadata, targetSourceFile);
if (!newDecorator) {
const annotationType = metadata.type;
return [{
node,
message: `Class cannot be migrated as the inherited @${annotationType} decorator ` +
`cannot be copied. Please manually add a @${annotationType} decorator.`,
}];
}
// In case the decorator could be constructed from the resolved metadata, use
// that decorator for the derived undecorated classes.
newDecoratorText =
this.printer.printNode(ts.EmitHint.Unspecified, newDecorator, targetSourceFile);
break;
}
if (!newDecoratorText) {
return [{
node,
message:
'Class cannot be migrated as no directive/component/pipe metadata could be found. ' +
'Please manually add a @Directive, @Component or @Pipe decorator.'
}];
}
this.getUpdateRecorder(targetSourceFile).addClassDecorator(node, newDecoratorText);
return [];
}
/** Records all changes that were made in the import manager. */
recordChanges() { this.importManager.recordChanges(); }
/**
* Constructs a TypeScript decorator node from the specified declaration metadata. Returns
* null if the metadata could not be simplified/resolved.
*/
private _constructDecoratorFromMetadata(
directiveMetadata: DeclarationMetadata, targetSourceFile: ts.SourceFile): ts.Decorator|null {
try {
const decoratorExpr = convertDirectiveMetadataToExpression(
directiveMetadata.metadata,
staticSymbol =>
this.compilerHost
.fileNameToModuleName(staticSymbol.filePath, targetSourceFile.fileName)
.replace(/\/index$/, ''),
(moduleName: string, name: string) =>
this.importManager.addImportToSourceFile(targetSourceFile, name, moduleName),
(propertyName, value) => {
// Only normalize properties called "changeDetection" and "encapsulation"
// for "@Directive" and "@Component" annotations.
if (directiveMetadata.type === 'Pipe') {
return null;
}
// Instead of using the number as value for the "changeDetection" and
// "encapsulation" properties, we want to use the actual enum symbols.
if (propertyName === 'changeDetection' && typeof value === 'number') {
return ts.createPropertyAccess(
this.importManager.addImportToSourceFile(
targetSourceFile, 'ChangeDetectionStrategy', '@angular/core'),
ChangeDetectionStrategy[value]);
} else if (propertyName === 'encapsulation' && typeof value === 'number') {
return ts.createPropertyAccess(
this.importManager.addImportToSourceFile(
targetSourceFile, 'ViewEncapsulation', '@angular/core'),
ViewEncapsulation[value]);
}
return null;
});
return ts.createDecorator(ts.createCall(
this.importManager.addImportToSourceFile(
targetSourceFile, directiveMetadata.type, '@angular/core'),
undefined, [decoratorExpr]));
} catch (e) {
if (e instanceof UnexpectedMetadataValueError) {
return null;
}
throw e;
}
}
/**
* Whether the given identifier resolves to a class declaration that
* has metadata for a directive.
*/
private _hasDirectiveMetadata(node: ts.Identifier): boolean {
const symbol = this._getStaticSymbolOfIdentifier(node);
if (!symbol) {
return false;
}
return this.metadataResolver.isDirective(symbol);
}
/**
* Resolves the declaration metadata of a given static symbol. The metadata
* is determined by resolving metadata for the static symbol.
*/
private _resolveDeclarationMetadata(symbol: StaticSymbol): null|DeclarationMetadata {
try {
// Note that this call can throw if the metadata is not computable. In that
// case we are not able to serialize the metadata into a decorator and we return
// null.
const annotations = this.compiler.reflector.annotations(symbol).find(
s => s.ngMetadataName === 'Component' || s.ngMetadataName === 'Directive' ||
s.ngMetadataName === 'Pipe');
if (!annotations) {
return null;
}
const {ngMetadataName, ...metadata} = annotations;
// Delete the "ngMetadataName" property as we don't want to generate
// a property assignment in the new decorator for that internal property.
delete metadata['ngMetadataName'];
return {type: ngMetadataName, metadata};
} catch (e) {
return null;
}
}
private _getStaticSymbolOfIdentifier(node: ts.Identifier): StaticSymbol|null {
const sourceFile = node.getSourceFile();
const resolvedImport = getImportOfIdentifier(this.typeChecker, node);
if (!resolvedImport) {
return null;
}
const moduleName =
this.compilerHost.moduleNameToFileName(resolvedImport.importModule, sourceFile.fileName);
if (!moduleName) {
return null;
}
// Find the declaration symbol as symbols could be aliased due to
// metadata re-exports.
return this.compiler.reflector.findSymbolDeclaration(
this.symbolResolver.getStaticSymbol(moduleName, resolvedImport.name));
}
/**
* Disables that static symbols are resolved through summaries. Summaries
* cannot be used for decorator analysis as decorators are omitted in summaries.
*/
private _disableSummaryResolution() {
// We never want to resolve symbols through summaries. Summaries never contain
// decorators for class symbols and therefore summaries will cause every class
// to be considered as undecorated. See reason for this in: "ToJsonSerializer".
// In order to ensure that metadata is not retrieved through summaries, we
// need to disable summary resolution, clear previous symbol caches. This way
// future calls to "StaticReflector#annotations" are based on metadata files.
this.symbolResolver['_resolveSymbolFromSummary'] = () => null;
this.symbolResolver['resolvedSymbols'].clear();
this.symbolResolver['resolvedFilePaths'].clear();
this.compiler.reflector['annotationCache'].clear();
// Original summary resolver used by the AOT compiler.
const summaryResolver = this.symbolResolver['summaryResolver'];
// Additionally we need to ensure that no files are treated as "library" files when
// resolving metadata. This is necessary because by default the symbol resolver discards
// class metadata for library files. See "StaticSymbolResolver#createResolvedSymbol".
// Patching this function **only** for the static symbol resolver ensures that metadata
// is not incorrectly omitted. Note that we only want to do this for the symbol resolver
// because otherwise we could break the summary loading logic which is used to detect
// if a static symbol is either a directive, component or pipe (see MetadataResolver).
this.symbolResolver['summaryResolver'] = <SummaryResolver<StaticSymbol>>{
fromSummaryFileName: summaryResolver.fromSummaryFileName.bind(summaryResolver),
addSummary: summaryResolver.addSummary.bind(summaryResolver),
getImportAs: summaryResolver.getImportAs.bind(summaryResolver),
getKnownModuleName: summaryResolver.getKnownModuleName.bind(summaryResolver),
resolveSummary: summaryResolver.resolveSummary.bind(summaryResolver),
toSummaryFileName: summaryResolver.toSummaryFileName.bind(summaryResolver),
getSymbolsOf: summaryResolver.getSymbolsOf.bind(summaryResolver),
isLibraryFile: () => false,
};
}
}

View File

@ -0,0 +1,23 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import * as ts from 'typescript';
/**
* Update recorder interface that is used to transform source files in a non-colliding
* way. Also this indirection makes it possible to re-use transformation logic with
* different replacement tools (e.g. TSLint or CLI devkit).
*/
export interface UpdateRecorder {
addNewImport(start: number, importText: string): void;
updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string): void;
addClassDecorator(node: ts.ClassDeclaration, text: string): void;
addClassComment(node: ts.ClassDeclaration, text: string): void;
commitUpdate(): void;
}

View File

@ -15,6 +15,7 @@ ts_library(
"//packages/core/schematics/migrations/renderer-to-renderer2",
"//packages/core/schematics/migrations/static-queries",
"//packages/core/schematics/migrations/template-var-assignment",
"//packages/core/schematics/migrations/undecorated-classes-with-di",
"//packages/core/schematics/utils",
"@npm//@angular-devkit/core",
"@npm//@angular-devkit/schematics",

View File

@ -0,0 +1,28 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* Template string function that can be used to dedent the resulting
* string literal. The smallest common indentation will be omitted.
*/
export function dedent(strings: TemplateStringsArray, ...values: any[]) {
let joinedString = '';
for (let i = 0; i < values.length; i++) {
joinedString += `${strings[i]}${values[i]}`;
}
joinedString += strings[strings.length - 1];
const matches = joinedString.match(/^[ \t]*(?=\S)/gm);
if (matches === null) {
return joinedString;
}
const minLineIndent = Math.min(...matches.map(el => el.length));
const omitMinIndentRegex = new RegExp(`^[ \\t]{${minLineIndent}}`, 'gm');
return minLineIndent > 0 ? joinedString.replace(omitMinIndentRegex, '') : joinedString;
}

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"types": [],
"baseUrl": ".",
"paths": {
"@angular/core": ["../"],
"@angular/compiler": ["../../compiler"],
"@angular/compiler/*": ["../../compiler/*"],
"@angular/compiler-cli": ["../../compiler-cli"],

View File

@ -15,6 +15,7 @@ export type CallExpressionDecorator = ts.Decorator & {
export interface NgDecorator {
name: string;
moduleName: string;
node: CallExpressionDecorator;
importNode: ts.ImportDeclaration;
}
@ -30,6 +31,7 @@ export function getAngularDecorators(
.map(({node, importData}) => ({
node: node as CallExpressionDecorator,
name: importData !.name,
moduleName: importData !.importModule,
importNode: importData !.node
}));
}

View File

@ -30,3 +30,8 @@ export function findParentClassDeclaration(node: ts.Node): ts.ClassDeclaration|n
}
return node;
}
/** Checks whether the given class declaration has an explicit constructor or not. */
export function hasExplicitConstructor(node: ts.ClassDeclaration): boolean {
return node.members.some(ts.isConstructorDeclaration);
}

View File

@ -0,0 +1,20 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import * as ts from 'typescript';
export function getValueSymbolOfDeclaration(node: ts.Node, typeChecker: ts.TypeChecker): ts.Symbol|
undefined {
let symbol = typeChecker.getSymbolAtLocation(node);
while (symbol && symbol.flags & ts.SymbolFlags.Alias) {
symbol = typeChecker.getAliasedSymbol(symbol);
}
return symbol;
}