Skip to content

refactor(ng-update): handle namespaced angular decorators in update-tool #17737

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Component} from '@angular/core';
import * as core from '@angular/core';
import {By} from '@angular/platform-browser';

const a = By.css('[cdkPortalOutlet]');
Expand All @@ -9,7 +10,7 @@ const b = By.css('[cdkPortalOutlet]');
const c = 'cdkPortalHost';
const d = 'portalHost';

@Component({
@core.Component({
template: `
<div cdkPortalOutlet="E"></div>
<div [cdkPortalOutlet]="myPortal"></div>
Expand All @@ -31,4 +32,4 @@ class E {}
'div[cdkPortalOutlet] {color: blue}'
]
})
class F {}
class F {}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Component} from '@angular/core';
import * as core from '@angular/core';
import {By} from '@angular/platform-browser';

const a = By.css('[cdkPortalHost]');
Expand All @@ -9,7 +10,7 @@ const b = By.css('[portalHost]');
const c = 'cdkPortalHost';
const d = 'portalHost';

@Component({
@core.Component({
template: `
<div cdkPortalHost="E"></div>
<div [cdkPortalHost]="myPortal"></div>
Expand All @@ -31,4 +32,4 @@ class E {}
'div[portalHost] {color: blue}'
]
})
class F {}
class F {}
12 changes: 10 additions & 2 deletions src/cdk/schematics/testing/test-case-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const TEST_CASE_INPUT_SUFFIX = '_input.ts';
/** Suffix that indicates whether a given file is an expected output of a test case. */
const TEST_CASE_OUTPUT_SUFFIX = '_expected_output.ts';

/** Name of the folder that can contain test case files which should not run automatically. */
const MISC_FOLDER_NAME = 'misc';

/** Reads the UTF8 content of the specified file. Normalizes the path and ensures that */
export function readFileContent(filePath: string): string {
return readFileSync(filePath, 'utf8');
Expand Down Expand Up @@ -135,7 +138,8 @@ export function findBazelVersionTestCases(basePath: string) {
// test case files by using "glob" and store them in our result map.
if (!manifestPath) {
const runfilesBaseDir = join(runfilesDir!, basePath);
const inputFiles = globSync(`**/*${TEST_CASE_INPUT_SUFFIX}`, {cwd: runfilesBaseDir});
const inputFiles = globSync(`**/!(${MISC_FOLDER_NAME})/*${TEST_CASE_INPUT_SUFFIX}`,
{cwd: runfilesBaseDir});

inputFiles.forEach(inputFile => {
// The target version of an input file will be determined from the first
Expand All @@ -158,9 +162,13 @@ export function findBazelVersionTestCases(basePath: string) {
// In case the mapped runfile starts with the specified base path and ends with "_input.ts",
// we store it in our result map because we assume that this is a test case.
if (runfilePath.startsWith(basePath) && runfilePath.endsWith(TEST_CASE_INPUT_SUFFIX)) {
const pathSegments = relative(basePath, runfilePath).split(sep);
if (pathSegments.includes(MISC_FOLDER_NAME)) {
return;
}
// The target version of an input file will be determined from the first
// path segment. (e.g. "v6/my_rule_input.ts" will be for "v6")
const targetVersion = relative(basePath, runfilePath).split(sep)[0];
const targetVersion = pathSegments[0];
testCasesMap.set(targetVersion, (testCasesMap.get(targetVersion) || []).concat(realPath));
}
});
Expand Down
2 changes: 2 additions & 0 deletions src/cdk/schematics/update-tool/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export * from './target-version';
export * from './version-changes';
export * from './migration-rule';
export * from './component-resource-collector';
export * from './utils/imports';
export * from './utils/decorators';
2 changes: 1 addition & 1 deletion src/cdk/schematics/update-tool/target-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ export enum TargetVersion {
*/
export function getAllVersionNames(): string[] {
return Object.keys(TargetVersion)
.filter(enumValue => typeof TargetVersion[enumValue] === 'number');
.filter(enumValue => typeof TargetVersion[enumValue] === 'string');
}
27 changes: 13 additions & 14 deletions src/cdk/schematics/update-tool/utils/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export type CallExpressionDecorator = ts.Decorator&{
export interface NgDecorator {
name: string;
node: CallExpressionDecorator;
importNode: ts.ImportDeclaration;
}

/**
Expand All @@ -27,23 +26,23 @@ export interface NgDecorator {
export function getAngularDecorators(
typeChecker: ts.TypeChecker, decorators: ReadonlyArray<ts.Decorator>): NgDecorator[] {
return decorators.map(node => ({node, importData: getCallDecoratorImport(typeChecker, node)}))
.filter(({importData}) => importData && importData.importModule.startsWith('@angular/'))
.map(({node, importData}) => ({
node: node as CallExpressionDecorator,
name: importData!.name,
importNode: importData!.node
}));
.filter(({importData}) => importData && importData.moduleName.startsWith('@angular/'))
.map(
({node, importData}) =>
({node: node as CallExpressionDecorator, name: importData!.symbolName}));
}

export function getCallDecoratorImport(
typeChecker: ts.TypeChecker, decorator: ts.Decorator): Import|null {
// Note that this does not cover the edge case where decorators are called from
// a namespace import: e.g. "@core.Component()". This is not handled by Ngtsc either.
if (!ts.isCallExpression(decorator.expression) ||
!ts.isIdentifier(decorator.expression.expression)) {
if (!ts.isCallExpression(decorator.expression)) {
return null;
}

const identifier = decorator.expression.expression;
return getImportOfIdentifier(typeChecker, identifier);
const valueExpr = decorator.expression.expression;
let identifier: ts.Identifier|null = null;
if (ts.isIdentifier(valueExpr)) {
identifier = valueExpr;
} else if (ts.isPropertyAccessExpression(valueExpr) && ts.isIdentifier(valueExpr.name)) {
identifier = valueExpr.name;
}
return identifier ? getImportOfIdentifier(identifier, typeChecker) : null;
}
122 changes: 100 additions & 22 deletions src/cdk/schematics/update-tool/utils/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,117 @@

import * as ts from 'typescript';

export type Import = {
name: string,
importModule: string,
node: ts.ImportDeclaration
};

/** Gets import information about the specified identifier by using the type checker. */
export function getImportOfIdentifier(typeChecker: ts.TypeChecker, node: ts.Identifier): Import|
/** Interface describing a resolved import. */
export interface Import {
/** Name of the imported symbol. */
symbolName: string;
/** Module name from which the symbol has been imported. */
moduleName: string;
}


/** Resolves the import of the specified identifier. */
export function getImportOfIdentifier(node: ts.Identifier, typeChecker: ts.TypeChecker): Import|
null {
const symbol = typeChecker.getSymbolAtLocation(node);
// Free standing identifiers which resolve to an import will be handled
// as direct imports. e.g. "@Component()" where "Component" is an identifier
// referring to an import specifier.
const directImport = getSpecificImportOfIdentifier(node, typeChecker);
if (directImport !== null) {
return directImport;
} else if (ts.isQualifiedName(node.parent) && node.parent.right === node) {
// Determines the import of a qualified name. e.g. "let t: core.Component". In that
// case, the import of the most left identifier will be determined ("core").
const qualifierRoot = getQualifiedNameRoot(node.parent);
if (qualifierRoot) {
const moduleName = getImportOfNamespacedIdentifier(qualifierRoot, typeChecker);
if (moduleName) {
return {moduleName, symbolName: node.text};
}
}
} else if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) {
// Determines the import of a property expression. e.g. "@core.Component". In that
// case, the import of the most left identifier will be determined ("core").
const rootIdentifier = getPropertyAccessRoot(node.parent);
if (rootIdentifier) {
const moduleName = getImportOfNamespacedIdentifier(rootIdentifier, typeChecker);
if (moduleName) {
return {moduleName, symbolName: node.text};
}
}
}
return null;
}

/**
* Resolves the import of the specified identifier. Expects the identifier to resolve
* to a fine-grained import declaration with import specifiers.
*/
function getSpecificImportOfIdentifier(node: ts.Identifier, typeChecker: ts.TypeChecker): Import|
null {
const symbol = typeChecker.getSymbolAtLocation(node);
if (!symbol || !symbol.declarations || !symbol.declarations.length) {
return null;
}

const decl = symbol.declarations[0];

if (!ts.isImportSpecifier(decl)) {
const declaration = symbol.declarations[0];
if (!ts.isImportSpecifier(declaration)) {
return null;
}

// Since "decl" is an import specifier, we can walk up three times to get a reference
// Since the declaration is an import specifier, we can walk up three times to get a reference
// to the import declaration node (NamedImports -> ImportClause -> ImportDeclaration).
const importDecl = decl.parent.parent.parent;

const importDecl = declaration.parent.parent.parent;
if (!ts.isStringLiteral(importDecl.moduleSpecifier)) {
return null;
}

return {
// Handles aliased imports: e.g. "import {Component as myComp} from ...";
name: decl.propertyName ? decl.propertyName.text : decl.name.text,
importModule: importDecl.moduleSpecifier.text,
node: importDecl
moduleName: importDecl.moduleSpecifier.text,
symbolName: declaration.propertyName ? declaration.propertyName.text : declaration.name.text
};
}

/**
* Resolves the import of the specified identifier. Expects the identifier to
* resolve to a namespaced import declaration. e.g. "import * as core from ...".
*/
function getImportOfNamespacedIdentifier(node: ts.Identifier, typeChecker: ts.TypeChecker): string|
null {
const symbol = typeChecker.getSymbolAtLocation(node);
if (!symbol || !symbol.declarations || !symbol.declarations.length) {
return null;
}
const declaration = symbol.declarations[0];
if (!ts.isNamespaceImport(declaration)) {
return null;
}
// Since the declaration is a namespace import, we can walk up three times to get a reference
// to the import declaration node (NamespaceImport -> ImportClause -> ImportDeclaration).
const importDecl = declaration.parent.parent;
if (!ts.isStringLiteral(importDecl.moduleSpecifier)) {
return null;
}

return importDecl.moduleSpecifier.text;
}


/**
* Gets the root identifier of a qualified type chain. For example: "core.GestureConfig"
* will return the "core" identifier. Allowing us to find the import of "core".
*/
function getQualifiedNameRoot(name: ts.QualifiedName): ts.Identifier|null {
while (ts.isQualifiedName(name.left)) {
name = name.left;
}
return ts.isIdentifier(name.left) ? name.left : null;
}

/**
* Gets the root identifier of a property access chain. For example: "core.GestureConfig"
* will return the "core" identifier. Allowing us to find the import of "core".
*/
function getPropertyAccessRoot(node: ts.PropertyAccessExpression): ts.Identifier|null {
while (ts.isPropertyAccessExpression(node.expression)) {
node = node.expression;
}
return ts.isIdentifier(node.expression) ? node.expression : null;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {createTestCaseSetup, readFileContent} from '@angular/cdk/schematics/testing';
import {migrationCollection} from '../index.spec';
import {migrationCollection} from '../../index.spec';

describe('v8 material imports', () => {
it('should re-map top-level material imports to the proper entry points', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import {addPackageToPackageJson} from '@angular/cdk/schematics/ng-add/package-co
import {createTestCaseSetup} from '@angular/cdk/schematics/testing';
import {readFileSync} from 'fs';

import {migrationCollection} from '../index.spec';
import {migrationCollection} from '../../index.spec';

describe('v9 HammerJS removal', () => {
const GESTURE_CONFIG_TEMPLATE_PATH =
require.resolve('../../upgrade-rules/hammer-gestures-v9/gesture-config.template');
require.resolve('../../../upgrade-rules/hammer-gestures-v9/gesture-config.template');

let runner: SchematicTestRunner;
let tree: UnitTestTree;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {createTestCaseSetup, readFileContent} from '@angular/cdk/schematics/testing';
import {migrationCollection} from '../index.spec';
import {migrationCollection} from '../../index.spec';

describe('v9 material imports', () => {
it('should re-map top-level material imports to the proper entry points when top-level ' +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
MigrationRule,
PostMigrationAction,
ResolvedResource,
TargetVersion
TargetVersion,
Import,
getImportOfIdentifier,
} from '@angular/cdk/schematics';
import {
addSymbolToNgModuleMetadata,
Expand All @@ -38,7 +40,6 @@ import {getProjectFromProgram} from './cli-workspace';
import {findHammerScriptImportElements} from './find-hammer-script-tags';
import {findMainModuleExpression} from './find-main-module';
import {isHammerJsUsedInTemplate} from './hammer-template-check';
import {getImportOfIdentifier, Import} from './identifier-imports';
import {ImportManager} from './import-manager';
import {removeElementFromArrayExpression} from './remove-array-element';
import {removeElementFromHtml} from './remove-element-from-html';
Expand Down
Loading