Skip to content

Commit 1071c59

Browse files
devversionsabeersulaiman
authored andcommitted
feat(core): add undecorated classes migration schematic (angular#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 angular#31650
1 parent 6e0ed94 commit 1071c59

22 files changed

+3107
-0
lines changed

packages/core/schematics/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ npm_package(
1515
"//packages/core/schematics/migrations/renderer-to-renderer2",
1616
"//packages/core/schematics/migrations/static-queries",
1717
"//packages/core/schematics/migrations/template-var-assignment",
18+
"//packages/core/schematics/migrations/undecorated-classes-with-di",
1819
],
1920
)

packages/core/schematics/migrations.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
"version": "9-beta",
2020
"description": "Migrates usages of Renderer to Renderer2",
2121
"factory": "./migrations/renderer-to-renderer2/index"
22+
},
23+
"migration-v9-undecorated-classes-with-di": {
24+
"version": "9-beta",
25+
"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.",
26+
"factory": "./migrations/undecorated-classes-with-di/index"
2227
}
2328
}
2429
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
ts_library(
4+
name = "undecorated-classes-with-di",
5+
srcs = glob(["**/*.ts"]),
6+
tsconfig = "//packages/core/schematics:tsconfig.json",
7+
visibility = [
8+
"//packages/core/schematics:__pkg__",
9+
"//packages/core/schematics/migrations/undecorated-classes/google3:__pkg__",
10+
"//packages/core/schematics/test:__pkg__",
11+
],
12+
deps = [
13+
"//packages/compiler",
14+
"//packages/compiler-cli",
15+
"//packages/compiler-cli/src/ngtsc/imports",
16+
"//packages/compiler-cli/src/ngtsc/partial_evaluator",
17+
"//packages/compiler-cli/src/ngtsc/reflection",
18+
"//packages/core",
19+
"//packages/core/schematics/utils",
20+
"@npm//@angular-devkit/core",
21+
"@npm//@angular-devkit/schematics",
22+
"@npm//@types/node",
23+
"@npm//typescript",
24+
],
25+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {AotCompiler, CompileStylesheetMetadata} from '@angular/compiler';
10+
import {createProgram, readConfiguration} from '@angular/compiler-cli';
11+
import * as ts from 'typescript';
12+
13+
/** Creates an NGC program that can be used to read and parse metadata for files. */
14+
export function createNgcProgram(
15+
createHost: (options: ts.CompilerOptions) => ts.CompilerHost, tsconfigPath: string | null,
16+
parseConfig: () => {
17+
rootNames: readonly string[],
18+
options: ts.CompilerOptions
19+
} = () => readConfiguration(tsconfigPath !)) {
20+
const {rootNames, options} = parseConfig();
21+
const host = createHost(options);
22+
const ngcProgram = createProgram({rootNames, options, host});
23+
const program = ngcProgram.getTsProgram();
24+
25+
// The "AngularCompilerProgram" does not expose the "AotCompiler" instance, nor does it
26+
// expose the logic that is necessary to analyze the determined modules. We work around
27+
// this by just accessing the necessary private properties using the bracket notation.
28+
const compiler: AotCompiler = (ngcProgram as any)['compiler'];
29+
const metadataResolver = compiler['_metadataResolver'];
30+
// Modify the "DirectiveNormalizer" to not normalize any referenced external stylesheets.
31+
// This is necessary because in CLI projects preprocessor files are commonly referenced
32+
// and we don't want to parse them in order to extract relative style references. This
33+
// breaks the analysis of the project because we instantiate a standalone AOT compiler
34+
// program which does not contain the custom logic by the Angular CLI Webpack compiler plugin.
35+
const directiveNormalizer = metadataResolver !['_directiveNormalizer'];
36+
directiveNormalizer['_normalizeStylesheet'] = function(metadata: CompileStylesheetMetadata) {
37+
return new CompileStylesheetMetadata(
38+
{styles: metadata.styles, styleUrls: [], moduleUrl: metadata.moduleUrl !});
39+
};
40+
41+
return {host, ngcProgram, program, compiler};
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {StaticSymbol} from '@angular/compiler';
10+
import * as ts from 'typescript';
11+
12+
/**
13+
* Converts a directive metadata object into a TypeScript expression. Throws
14+
* if metadata cannot be cleanly converted.
15+
*/
16+
export function convertDirectiveMetadataToExpression(
17+
metadata: any, resolveSymbolImport: (symbol: StaticSymbol) => string | null,
18+
createImport: (moduleName: string, name: string) => ts.Expression,
19+
convertProperty?: (key: string, value: any) => ts.Expression | null): ts.Expression {
20+
if (typeof metadata === 'string') {
21+
return ts.createStringLiteral(metadata);
22+
} else if (Array.isArray(metadata)) {
23+
return ts.createArrayLiteral(metadata.map(
24+
el => convertDirectiveMetadataToExpression(
25+
el, resolveSymbolImport, createImport, convertProperty)));
26+
} else if (typeof metadata === 'number') {
27+
return ts.createNumericLiteral(metadata.toString());
28+
} else if (typeof metadata === 'boolean') {
29+
return metadata ? ts.createTrue() : ts.createFalse();
30+
} else if (typeof metadata === 'undefined') {
31+
return ts.createIdentifier('undefined');
32+
} else if (typeof metadata === 'bigint') {
33+
return ts.createBigIntLiteral(metadata.toString());
34+
} else if (typeof metadata === 'object') {
35+
// In case there is a static symbol object part of the metadata, try to resolve
36+
// the import expression of the symbol. If no import path could be resolved, an
37+
// error will be thrown as the symbol cannot be converted into TypeScript AST.
38+
if (metadata instanceof StaticSymbol) {
39+
const resolvedImport = resolveSymbolImport(metadata);
40+
if (resolvedImport === null) {
41+
throw new UnexpectedMetadataValueError();
42+
}
43+
return createImport(resolvedImport, metadata.name);
44+
}
45+
46+
const literalProperties: ts.PropertyAssignment[] = [];
47+
48+
for (const key of Object.keys(metadata)) {
49+
const metadataValue = metadata[key];
50+
let propertyValue: ts.Expression|null = null;
51+
52+
// Allows custom conversion of properties in an object. This is useful for special
53+
// cases where we don't want to store the enum values as integers, but rather use the
54+
// real enum symbol. e.g. instead of `2` we want to use `ViewEncapsulation.None`.
55+
if (convertProperty) {
56+
propertyValue = convertProperty(key, metadataValue);
57+
}
58+
59+
// In case the property value has not been assigned to an expression, we convert
60+
// the resolved metadata value into a TypeScript expression.
61+
if (propertyValue === null) {
62+
propertyValue = convertDirectiveMetadataToExpression(
63+
metadataValue, resolveSymbolImport, createImport, convertProperty);
64+
}
65+
66+
literalProperties.push(ts.createPropertyAssignment(key, propertyValue));
67+
}
68+
69+
return ts.createObjectLiteral(literalProperties, true);
70+
}
71+
72+
throw new UnexpectedMetadataValueError();
73+
}
74+
75+
/** Error that will be thrown if a unexpected value needs to be converted. */
76+
export class UnexpectedMetadataValueError extends Error {}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
2+
/**
3+
* @license
4+
* Copyright Google Inc. All Rights Reserved.
5+
*
6+
* Use of this source code is governed by an MIT-style license that can be
7+
* found in the LICENSE file at https://angular.io/license
8+
*/
9+
import {AotCompiler} from '@angular/compiler';
10+
import {PartialEvaluator} from '@angular/compiler-cli/src/ngtsc/partial_evaluator';
11+
import * as ts from 'typescript';
12+
13+
import {NgDecorator} from '../../../utils/ng_decorators';
14+
import {unwrapExpression} from '../../../utils/typescript/functions';
15+
import {ImportManager} from '../import_manager';
16+
17+
import {ImportRewriteTransformerFactory, UnresolvedIdentifierError} from './import_rewrite_visitor';
18+
19+
/**
20+
* Class that can be used to copy decorators to a new location. The rewriter ensures that
21+
* identifiers and imports are rewritten to work in the new file location. Fields in a
22+
* decorator that cannot be cleanly copied will be copied with a comment explaining that
23+
* imports and identifiers need to be adjusted manually.
24+
*/
25+
export class DecoratorRewriter {
26+
previousSourceFile: ts.SourceFile|null = null;
27+
newSourceFile: ts.SourceFile|null = null;
28+
29+
newProperties: ts.ObjectLiteralElementLike[] = [];
30+
nonCopyableProperties: ts.ObjectLiteralElementLike[] = [];
31+
32+
private importRewriterFactory = new ImportRewriteTransformerFactory(
33+
this.importManager, this.typeChecker, this.compiler['_host']);
34+
35+
constructor(
36+
private importManager: ImportManager, private typeChecker: ts.TypeChecker,
37+
private evaluator: PartialEvaluator, private compiler: AotCompiler) {}
38+
39+
rewrite(ngDecorator: NgDecorator, newSourceFile: ts.SourceFile): ts.Decorator {
40+
const decorator = ngDecorator.node;
41+
42+
// Reset the previous state of the decorator rewriter.
43+
this.newProperties = [];
44+
this.nonCopyableProperties = [];
45+
this.newSourceFile = newSourceFile;
46+
this.previousSourceFile = decorator.getSourceFile();
47+
48+
// If the decorator will be added to the same source file it currently
49+
// exists in, we don't need to rewrite any paths or add new imports.
50+
if (this.previousSourceFile === newSourceFile) {
51+
return this._createDecorator(decorator.expression);
52+
}
53+
54+
const oldCallExpr = decorator.expression;
55+
56+
if (!oldCallExpr.arguments.length) {
57+
// Re-use the original decorator if there are no arguments and nothing needs
58+
// to be sanitized or rewritten.
59+
return this._createDecorator(decorator.expression);
60+
}
61+
62+
const metadata = unwrapExpression(oldCallExpr.arguments[0]);
63+
if (!ts.isObjectLiteralExpression(metadata)) {
64+
// Re-use the original decorator as there is no metadata that can be sanitized.
65+
return this._createDecorator(decorator.expression);
66+
}
67+
68+
metadata.properties.forEach(prop => {
69+
// We don't handle spread assignments, accessors or method declarations automatically
70+
// as it involves more advanced static analysis and these type of properties are not
71+
// picked up by ngc either.
72+
if (ts.isSpreadAssignment(prop) || ts.isAccessor(prop) || ts.isMethodDeclaration(prop)) {
73+
this.nonCopyableProperties.push(prop);
74+
return;
75+
}
76+
77+
const sanitizedProp = this._sanitizeMetadataProperty(prop);
78+
if (sanitizedProp !== null) {
79+
this.newProperties.push(sanitizedProp);
80+
} else {
81+
this.nonCopyableProperties.push(prop);
82+
}
83+
});
84+
85+
// In case there is at least one non-copyable property, we add a leading comment to
86+
// the first property assignment in order to ask the developer to manually manage
87+
// imports and do path rewriting for these properties.
88+
if (this.nonCopyableProperties.length !== 0) {
89+
['The following fields were copied from the base class,',
90+
'but could not be updated automatically to work in the',
91+
'new file location. Please add any required imports for', 'the properties below:']
92+
.forEach(
93+
text => ts.addSyntheticLeadingComment(
94+
this.nonCopyableProperties[0], ts.SyntaxKind.SingleLineCommentTrivia, ` ${text}`,
95+
true));
96+
}
97+
98+
// Note that we don't update the decorator as we don't want to copy potential leading
99+
// comments of the decorator. This is necessary because otherwise comments from the
100+
// copied decorator end up describing the new class (which is not always correct).
101+
return this._createDecorator(ts.createCall(
102+
this.importManager.addImportToSourceFile(
103+
newSourceFile, ngDecorator.name, ngDecorator.moduleName),
104+
undefined, [ts.updateObjectLiteral(
105+
metadata, [...this.newProperties, ...this.nonCopyableProperties])]));
106+
}
107+
108+
/** Creates a new decorator with the given expression. */
109+
private _createDecorator(expr: ts.Expression): ts.Decorator {
110+
// Note that we don't update the decorator as we don't want to copy potential leading
111+
// comments of the decorator. This is necessary because otherwise comments from the
112+
// copied decorator end up describing the new class (which is not always correct).
113+
return ts.createDecorator(expr);
114+
}
115+
116+
/**
117+
* Sanitizes a metadata property by ensuring that all contained identifiers
118+
* are imported in the target source file.
119+
*/
120+
private _sanitizeMetadataProperty(prop: ts.ObjectLiteralElementLike): ts.ObjectLiteralElementLike
121+
|null {
122+
try {
123+
return ts
124+
.transform(prop, [ctx => this.importRewriterFactory.create(ctx, this.newSourceFile !)])
125+
.transformed[0];
126+
} catch (e) {
127+
// If the error is for an unresolved identifier, we want to return "null" because
128+
// such object literal elements could be added to the non-copyable properties.
129+
if (e instanceof UnresolvedIdentifierError) {
130+
return null;
131+
}
132+
throw e;
133+
}
134+
}
135+
}

0 commit comments

Comments
 (0)