Skip to content

Commit

Permalink
feat(core): add undecorated classes migration schematic (angular#31650)
Browse files Browse the repository at this point in the history
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 angular#31650
  • Loading branch information
devversion authored and sabeersulaiman committed Sep 6, 2019
1 parent 6e0ed94 commit 1071c59
Show file tree
Hide file tree
Showing 22 changed files with 3,107 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/core/schematics/BUILD.bazel
Expand Up @@ -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",
],
)
5 changes: 5 additions & 0 deletions packages/core/schematics/migrations.json
Expand Up @@ -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"
}
}
}
@@ -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",
],
)
@@ -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};
}
@@ -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 {}
@@ -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;
}
}
}

0 comments on commit 1071c59

Please sign in to comment.