From 62157990554306fb76e1e254b31855f966ff65f2 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 27 Feb 2019 20:30:38 +0100 Subject: [PATCH] feat(core): update schematic to migrate to explicit query timing (#28983) Introduces an update schematic for the "@angular/core" package that automatically migrates pre-V8 "ViewChild" and "ContentChild" queries to the new explicit timing syntax. This is not required yet, but with Ivy, queries will be "dynamic" by default. Therefore specifying an explicit query timing ensures that developers can smoothly migrate to Ivy (once it's the default). Read more about the explicit timing API here: https://github.com/angular/angular/pull/28810 PR Close #28983 --- packages/core/BUILD.bazel | 3 + packages/core/package.json | 1 + packages/core/schematics/BUILD.bazel | 13 + packages/core/schematics/migrations.json | 9 + .../migrations/static-queries/BUILD.bazel | 20 + .../angular/analyze_query_usage.ts | 84 +++ .../angular/declaration_usage_visitor.ts | 88 +++ .../static-queries/angular/decorators.ts | 26 + .../angular/directive_inputs.ts | 88 +++ .../angular/ng_query_visitor.ts | 131 +++++ .../angular/query-definition.ts | 37 ++ .../migrations/static-queries/index.ts | 29 + .../migrations/static-queries/migration.ts | 97 ++++ .../typescript/class_declaration.ts | 32 ++ .../static-queries/typescript/decorators.ts | 24 + .../static-queries/typescript/imports.ts | 42 ++ .../typescript/property_name.ts | 28 + .../static-queries/typescript/tsconfig.ts | 21 + packages/core/schematics/test/BUILD.bazel | 22 + .../test/project_tsconfig_paths_spec.ts | 47 ++ .../test/static_queries_migration_spec.ts | 524 ++++++++++++++++++ packages/core/schematics/tsconfig.json | 8 + packages/core/schematics/utils/BUILD.bazel | 12 + .../utils/project_tsconfig_paths.ts | 70 +++ 24 files changed, 1456 insertions(+) create mode 100644 packages/core/schematics/BUILD.bazel create mode 100644 packages/core/schematics/migrations.json create mode 100644 packages/core/schematics/migrations/static-queries/BUILD.bazel create mode 100644 packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts create mode 100644 packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts create mode 100644 packages/core/schematics/migrations/static-queries/angular/decorators.ts create mode 100644 packages/core/schematics/migrations/static-queries/angular/directive_inputs.ts create mode 100644 packages/core/schematics/migrations/static-queries/angular/ng_query_visitor.ts create mode 100644 packages/core/schematics/migrations/static-queries/angular/query-definition.ts create mode 100644 packages/core/schematics/migrations/static-queries/index.ts create mode 100644 packages/core/schematics/migrations/static-queries/migration.ts create mode 100644 packages/core/schematics/migrations/static-queries/typescript/class_declaration.ts create mode 100644 packages/core/schematics/migrations/static-queries/typescript/decorators.ts create mode 100644 packages/core/schematics/migrations/static-queries/typescript/imports.ts create mode 100644 packages/core/schematics/migrations/static-queries/typescript/property_name.ts create mode 100644 packages/core/schematics/migrations/static-queries/typescript/tsconfig.ts create mode 100644 packages/core/schematics/test/BUILD.bazel create mode 100644 packages/core/schematics/test/project_tsconfig_paths_spec.ts create mode 100644 packages/core/schematics/test/static_queries_migration_spec.ts create mode 100644 packages/core/schematics/tsconfig.json create mode 100644 packages/core/schematics/utils/BUILD.bazel create mode 100644 packages/core/schematics/utils/project_tsconfig_paths.ts diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 1fcc01021e..a722331a72 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -29,6 +29,9 @@ ng_package( "//packages/core/testing:package.json", ], entry_point = "packages/core/index.js", + packages = [ + "//packages/core/schematics:npm_package", + ], tags = [ "release-with-framework", ], diff --git a/packages/core/package.json b/packages/core/package.json index cf624f468d..fea6ca27ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,7 @@ "url": "https://github.com/angular/angular.git" }, "ng-update": { + "migrations":"./schematics/migrations.json", "packageGroup": "NG_UPDATE_PACKAGE_GROUP" }, "sideEffects": false diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel new file mode 100644 index 0000000000..9bb25621fa --- /dev/null +++ b/packages/core/schematics/BUILD.bazel @@ -0,0 +1,13 @@ +load("//tools:defaults.bzl", "npm_package") + +exports_files([ + "tsconfig.json", + "migrations.json", +]) + +npm_package( + name = "npm_package", + srcs = ["migrations.json"], + visibility = ["//packages/core:__pkg__"], + deps = ["//packages/core/schematics/migrations/static-queries"], +) diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json new file mode 100644 index 0000000000..8293d9f2e8 --- /dev/null +++ b/packages/core/schematics/migrations.json @@ -0,0 +1,9 @@ +{ + "schematics": { + "migration-v8-static-queries": { + "version": "8", + "description": "Migrates ViewChild and ContentChild to explicit query timing", + "factory": "./migrations/static-queries/index" + } + } +} diff --git a/packages/core/schematics/migrations/static-queries/BUILD.bazel b/packages/core/schematics/migrations/static-queries/BUILD.bazel new file mode 100644 index 0000000000..7c2f0e9c63 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/BUILD.bazel @@ -0,0 +1,20 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "static-queries", + srcs = glob( + ["**/*.ts"], + exclude = ["index_spec.ts"], + ), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], + deps = [ + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts b/packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts new file mode 100644 index 0000000000..a6debd1c53 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts @@ -0,0 +1,84 @@ +/** + * @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 {hasPropertyNameText} from '../typescript/property_name'; +import {DeclarationUsageVisitor} from './declaration_usage_visitor'; +import {ClassMetadataMap} from './ng_query_visitor'; +import {NgQueryDefinition, QueryTiming, QueryType} from './query-definition'; + + +/** + * Object that maps a given type of query to a list of lifecycle hooks that + * could be used to access such a query statically. + */ +const STATIC_QUERY_LIFECYCLE_HOOKS = { + [QueryType.ViewChild]: ['ngOnInit', 'ngAfterContentInit', 'ngAfterContentChecked'], + [QueryType.ContentChild]: ['ngOnInit'], +}; + +/** + * Analyzes the usage of the given query and determines the query timing based + * on the current usage of the query. + */ +export function analyzeNgQueryUsage( + query: NgQueryDefinition, classMetadata: ClassMetadataMap, + typeChecker: ts.TypeChecker): QueryTiming { + return isQueryUsedStatically(query.container, query, classMetadata, typeChecker, []) ? + QueryTiming.STATIC : + QueryTiming.DYNAMIC; +} + +/** Checks whether a given class or it's derived classes use the specified query statically. */ +function isQueryUsedStatically( + classDecl: ts.ClassDeclaration, query: NgQueryDefinition, classMetadataMap: ClassMetadataMap, + typeChecker: ts.TypeChecker, knownInputNames: string[]): boolean { + const usageVisitor = new DeclarationUsageVisitor(query.property, typeChecker); + const classMetadata = classMetadataMap.get(classDecl); + + // In case there is metadata for the current class, we collect all resolved Angular input + // names and add them to the list of known inputs that need to be checked for usages of + // the current query. e.g. queries used in an @Input() *setter* are always static. + if (classMetadata) { + knownInputNames.push(...classMetadata.ngInputNames); + } + + // List of TypeScript nodes which can contain usages of the given query in order to + // access it statically. e.g. + // (1) queries used in the "ngOnInit" lifecycle hook are static. + // (2) inputs with setters can access queries statically. + const possibleStaticQueryNodes: ts.Node[] = classDecl.members.filter(m => { + if (ts.isMethodDeclaration(m) && hasPropertyNameText(m.name) && + STATIC_QUERY_LIFECYCLE_HOOKS[query.type].indexOf(m.name.text) !== -1) { + return true; + } else if ( + knownInputNames && ts.isSetAccessor(m) && hasPropertyNameText(m.name) && + knownInputNames.indexOf(m.name.text) !== -1) { + return true; + } + return false; + }); + + // In case nodes that can possibly access a query statically have been found, check + // if the query declaration is used within any of these nodes. + if (possibleStaticQueryNodes.length && + possibleStaticQueryNodes.some(hookNode => usageVisitor.isUsedInNode(hookNode))) { + return true; + } + + // In case there are classes that derive from the current class, visit each + // derived class as inherited queries could be used statically. + if (classMetadata) { + return classMetadata.derivedClasses.some( + derivedClass => isQueryUsedStatically( + derivedClass, query, classMetadataMap, typeChecker, knownInputNames)); + } + + return false; +} diff --git a/packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts b/packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts new file mode 100644 index 0000000000..db6a7c7ac5 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts @@ -0,0 +1,88 @@ +/** + * @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'; + +/** + * Class that can be used to determine if a given TypeScript node is used within + * other given TypeScript nodes. This is achieved by walking through all children + * of the given node and checking for usages of the given declaration. The visitor + * also handles potential control flow changes caused by call/new expressions. + */ +export class DeclarationUsageVisitor { + /** Set of visited symbols that caused a jump in control flow. */ + private visitedJumpExprSymbols = new Set(); + + constructor(private declaration: ts.Node, private typeChecker: ts.TypeChecker) {} + + private isReferringToSymbol(node: ts.Node): boolean { + const symbol = this.typeChecker.getSymbolAtLocation(node); + return !!symbol && symbol.valueDeclaration === this.declaration; + } + + private addJumpExpressionToQueue(node: ts.Expression, nodeQueue: ts.Node[]) { + const callExprSymbol = this.typeChecker.getSymbolAtLocation(node); + + // Note that we should not add previously visited symbols to the queue as this + // could cause cycles. + if (callExprSymbol && callExprSymbol.valueDeclaration && + !this.visitedJumpExprSymbols.has(callExprSymbol)) { + this.visitedJumpExprSymbols.add(callExprSymbol); + nodeQueue.push(callExprSymbol.valueDeclaration); + } + } + + private addNewExpressionToQueue(node: ts.NewExpression, nodeQueue: ts.Node[]) { + const newExprSymbol = this.typeChecker.getSymbolAtLocation(node.expression); + + // Only handle new expressions which resolve to classes. Technically "new" could + // also call void functions or objects with a constructor signature. Also note that + // we should not visit already visited symbols as this could cause cycles. + if (!newExprSymbol || !newExprSymbol.valueDeclaration || + !ts.isClassDeclaration(newExprSymbol.valueDeclaration) || + this.visitedJumpExprSymbols.has(newExprSymbol)) { + return; + } + + const targetConstructor = + newExprSymbol.valueDeclaration.members.find(d => ts.isConstructorDeclaration(d)); + + if (targetConstructor) { + this.visitedJumpExprSymbols.add(newExprSymbol); + nodeQueue.push(targetConstructor); + } + } + + isUsedInNode(searchNode: ts.Node): boolean { + const nodeQueue: ts.Node[] = [searchNode]; + this.visitedJumpExprSymbols.clear(); + + while (nodeQueue.length) { + const node = nodeQueue.shift() !; + + if (ts.isIdentifier(node) && this.isReferringToSymbol(node)) { + return true; + } + + // Handle call expressions within TypeScript nodes that cause a jump in control + // flow. We resolve the call expression value declaration and add it to the node queue. + if (ts.isCallExpression(node)) { + this.addJumpExpressionToQueue(node.expression, nodeQueue); + } + + // Handle new expressions that cause a jump in control flow. We resolve the + // constructor declaration of the target class and add it to the node queue. + if (ts.isNewExpression(node)) { + this.addNewExpressionToQueue(node, nodeQueue); + } + + nodeQueue.push(...node.getChildren()); + } + return false; + } +} diff --git a/packages/core/schematics/migrations/static-queries/angular/decorators.ts b/packages/core/schematics/migrations/static-queries/angular/decorators.ts new file mode 100644 index 0000000000..2e11f6966d --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/decorators.ts @@ -0,0 +1,26 @@ +/** + * @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 {getCallDecoratorImport} from '../typescript/decorators'; + +export interface NgDecorator { + name: string; + node: ts.Decorator; +} + +/** + * Gets all decorators which are imported from an Angular package (e.g. "@angular/core") + * from a list of decorators. + */ +export function getAngularDecorators( + typeChecker: ts.TypeChecker, decorators: ReadonlyArray): NgDecorator[] { + return decorators.map(node => ({node, importData: getCallDecoratorImport(typeChecker, node)})) + .filter(({importData}) => importData && importData.importModule.startsWith('@angular/')) + .map(({node, importData}) => ({node, name: importData !.name})); +} diff --git a/packages/core/schematics/migrations/static-queries/angular/directive_inputs.ts b/packages/core/schematics/migrations/static-queries/angular/directive_inputs.ts new file mode 100644 index 0000000000..b9c0b661e5 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/directive_inputs.ts @@ -0,0 +1,88 @@ +/** + * @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 {getPropertyNameText, hasPropertyNameText} from '../typescript/property_name'; +import {getAngularDecorators} from './decorators'; + +/** Analyzes the given class and resolves the name of all inputs which are declared. */ +export function getInputNamesOfClass( + node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): string[] { + const resolvedInputSetters: string[] = []; + + // Determines the names of all inputs defined in the current class declaration by + // checking whether a given property/getter/setter has the "@Input" decorator applied. + node.members.forEach(m => { + if (!m.decorators || !m.decorators.length || + !ts.isPropertyDeclaration(m) && !ts.isSetAccessor(m) && !ts.isGetAccessor(m)) { + return; + } + + const inputDecorator = + getAngularDecorators(typeChecker, m.decorators !).find(d => d.name === 'Input'); + + if (inputDecorator && hasPropertyNameText(m.name)) { + resolvedInputSetters.push(m.name.text); + } + }); + + // Besides looking for immediate setters in the current class declaration, developers + // can also define inputs in the directive metadata using the "inputs" property. We + // also need to determine these inputs which are declared in the directive metadata. + const metadataInputs = getInputNamesFromMetadata(node, typeChecker); + + if (metadataInputs) { + resolvedInputSetters.push(...metadataInputs); + } + + return resolvedInputSetters; +} + +/** + * Determines the names of all inputs declared in the directive/component metadata + * of the given class. + */ +function getInputNamesFromMetadata( + node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): string[]|null { + if (!node.decorators || !node.decorators.length) { + return null; + } + + const decorator = getAngularDecorators(typeChecker, node.decorators) + .find(d => d.name === 'Directive' || d.name === 'Component'); + + // In case no directive/component decorator could be found for this class, just + // return null as there is no metadata where an input could be declared. + if (!decorator) { + return null; + } + + const decoratorCall = decorator.node.expression as ts.CallExpression; + + // In case the decorator does define any metadata, there is no metadata + // where inputs could be declared. This is an edge case because there + // always needs to be an object literal, but in case there isn't we just + // want to skip the invalid decorator and return null. + if (!ts.isObjectLiteralExpression(decoratorCall.arguments[0])) { + return null; + } + + const metadata = decoratorCall.arguments[0] as ts.ObjectLiteralExpression; + const inputs = metadata.properties.filter(ts.isPropertyAssignment) + .find(p => getPropertyNameText(p.name) === 'inputs'); + + // In case there is no "inputs" property in the directive metadata, + // just return "null" as no inputs can be declared for this class. + if (!inputs || !ts.isArrayLiteralExpression(inputs.initializer)) { + return null; + } + + return inputs.initializer.elements.filter(ts.isStringLiteralLike) + .map(element => element.text.split(':')[0].trim()); +} diff --git a/packages/core/schematics/migrations/static-queries/angular/ng_query_visitor.ts b/packages/core/schematics/migrations/static-queries/angular/ng_query_visitor.ts new file mode 100644 index 0000000000..777ee8bbf7 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/ng_query_visitor.ts @@ -0,0 +1,131 @@ +/** + * @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 {findParentClassDeclaration, getBaseTypeIdentifiers} from '../typescript/class_declaration'; + +import {getAngularDecorators} from './decorators'; +import {getInputNamesOfClass} from './directive_inputs'; +import {NgQueryDefinition, QueryType} from './query-definition'; + +/** Resolved metadata of a given class. */ +export interface ClassMetadata { + /** List of class declarations that derive from the given class. */ + derivedClasses: ts.ClassDeclaration[]; + /** List of property names that declare an Angular input within the given class. */ + ngInputNames: string[]; +} + +/** Type that describes a map which can be used to get a class declaration's metadata. */ +export type ClassMetadataMap = Map; + +/** + * Visitor that can be used to determine Angular queries within given TypeScript nodes. + * Besides resolving queries, the visitor also records class relations and searches for + * Angular input setters which can be used to analyze the timing usage of a given query. + */ +export class NgQueryResolveVisitor { + /** Resolved Angular query definitions. */ + resolvedQueries = new Map(); + + /** Maps a class declaration to its class metadata. */ + classMetadata: ClassMetadataMap = new Map(); + + constructor(public typeChecker: ts.TypeChecker) {} + + visitNode(node: ts.Node) { + switch (node.kind) { + case ts.SyntaxKind.PropertyDeclaration: + this.visitPropertyDeclaration(node as ts.PropertyDeclaration); + break; + case ts.SyntaxKind.ClassDeclaration: + this.visitClassDeclaration(node as ts.ClassDeclaration); + break; + } + + ts.forEachChild(node, node => this.visitNode(node)); + } + + private visitPropertyDeclaration(node: ts.PropertyDeclaration) { + if (!node.decorators || !node.decorators.length) { + return; + } + + const ngDecorators = getAngularDecorators(this.typeChecker, node.decorators); + const queryDecorator = + ngDecorators.find(({name}) => name === 'ViewChild' || name === 'ContentChild'); + + // Ensure that the current property declaration is defining a query. + if (!queryDecorator) { + return; + } + + const queryContainer = findParentClassDeclaration(node); + + // If the query is not located within a class declaration, skip this node. + if (!queryContainer) { + return; + } + + const sourceFile = node.getSourceFile(); + const newQueries = this.resolvedQueries.get(sourceFile) || []; + + this.resolvedQueries.set(sourceFile, newQueries.concat({ + type: queryDecorator.name === 'ViewChild' ? QueryType.ViewChild : QueryType.ContentChild, + property: node, + decorator: queryDecorator, + container: queryContainer, + })); + } + + private visitClassDeclaration(node: ts.ClassDeclaration) { + this._recordClassInputSetters(node); + this._recordClassInheritances(node); + } + + private _recordClassInputSetters(node: ts.ClassDeclaration) { + const resolvedInputNames = getInputNamesOfClass(node, this.typeChecker); + + if (resolvedInputNames) { + const classMetadata = this._getClassMetadata(node); + + classMetadata.ngInputNames = resolvedInputNames; + this.classMetadata.set(node, classMetadata); + } + } + + private _recordClassInheritances(node: ts.ClassDeclaration) { + const baseTypes = getBaseTypeIdentifiers(node); + + if (!baseTypes || !baseTypes.length) { + return; + } + + baseTypes.forEach(baseTypeIdentifier => { + // We need to resolve the value declaration through the resolved type as the base + // class could be declared in different source files and the local symbol won't + // contain a value declaration as the value is not declared locally. + const symbol = this.typeChecker.getTypeAtLocation(baseTypeIdentifier).getSymbol(); + + if (symbol && symbol.valueDeclaration && ts.isClassDeclaration(symbol.valueDeclaration)) { + const extendedClass = symbol.valueDeclaration; + const classMetadata = this._getClassMetadata(extendedClass); + + // Record all classes that derive from the given class. This makes it easy to + // determine all classes that could potentially use inherited queries statically. + classMetadata.derivedClasses.push(node); + this.classMetadata.set(extendedClass, classMetadata); + } + }); + } + + private _getClassMetadata(node: ts.ClassDeclaration): ClassMetadata { + return this.classMetadata.get(node) || {derivedClasses: [], ngInputNames: []}; + } +} diff --git a/packages/core/schematics/migrations/static-queries/angular/query-definition.ts b/packages/core/schematics/migrations/static-queries/angular/query-definition.ts new file mode 100644 index 0000000000..1a9fbb00c0 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/angular/query-definition.ts @@ -0,0 +1,37 @@ +/** + * @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 {NgDecorator} from './decorators'; + + +/** Timing of a given query. Either static or dynamic. */ +export enum QueryTiming { + STATIC, + DYNAMIC +} + +/** Type of a given query. */ +export enum QueryType { + ViewChild, + ContentChild +} + +export interface NgQueryDefinition { + /** Type of the query definition. */ + type: QueryType; + + /** Property that declares the query. */ + property: ts.PropertyDeclaration; + + /** Decorator that declares this as a query. */ + decorator: NgDecorator; + + /** Class declaration that holds this query. */ + container: ts.ClassDeclaration; +} diff --git a/packages/core/schematics/migrations/static-queries/index.ts b/packages/core/schematics/migrations/static-queries/index.ts new file mode 100644 index 0000000000..e0149a4fb2 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/index.ts @@ -0,0 +1,29 @@ +/** + * @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 {Rule, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {runStaticQueryMigration} from './migration'; + +/** Entry point for the V8 static-query migration. */ +export default function(): Rule { + return (tree: Tree) => { + const projectTsConfigPaths = getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + + if (!projectTsConfigPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot migrate queries ' + + 'to explicit timing.'); + } + + for (const tsconfigPath of projectTsConfigPaths) { + runStaticQueryMigration(tree, tsconfigPath, basePath); + } + }; +} diff --git a/packages/core/schematics/migrations/static-queries/migration.ts b/packages/core/schematics/migrations/static-queries/migration.ts new file mode 100644 index 0000000000..d54a08b784 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/migration.ts @@ -0,0 +1,97 @@ +/** + * @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 {Tree, UpdateRecorder} from '@angular-devkit/schematics'; +import {dirname, relative, resolve} from 'path'; +import * as ts from 'typescript'; + +import {analyzeNgQueryUsage} from './angular/analyze_query_usage'; +import {NgQueryResolveVisitor} from './angular/ng_query_visitor'; +import {NgQueryDefinition, QueryTiming} from './angular/query-definition'; +import {getPropertyNameText} from './typescript/property_name'; +import {parseTsconfigFile} from './typescript/tsconfig'; + +/** + * Runs the static query migration for the given TypeScript project. The schematic + * analyzes all queries within the project and sets up the query timing based on + * the current usage of the query property. e.g. a view query that is not used in any + * lifecycle hook does not need to be static and can be set up with "static: false". + */ +export function runStaticQueryMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const parsed = parseTsconfigFile(tsconfigPath, dirname(tsconfigPath)); + const host = ts.createCompilerHost(parsed.options, true); + const program = ts.createProgram(parsed.fileNames, parsed.options, host); + const typeChecker = program.getTypeChecker(); + const queryVisitor = new NgQueryResolveVisitor(typeChecker); + const rootSourceFiles = program.getRootFileNames().map(f => program.getSourceFile(f) !); + const printer = ts.createPrinter(); + + // Analyze source files by detecting queries and class relations. + rootSourceFiles.forEach(sourceFile => queryVisitor.visitNode(sourceFile)); + + const {resolvedQueries, classMetadata} = queryVisitor; + + // Walk through all source files that contain resolved queries and update + // the source files if needed. Note that we need to update multiple queries + // within a source file within the same recorder in order to not throw off + // the TypeScript node offsets. + resolvedQueries.forEach((queries, sourceFile) => { + const update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + + // Compute the query usage for all resolved queries and update the + // query definitions to explicitly declare the query timing (static or dynamic) + queries.forEach(q => { + const timing = analyzeNgQueryUsage(q, classMetadata, typeChecker); + recordQueryUsageTransformation(q, update, timing, printer, sourceFile); + }); + + tree.commitUpdate(update); + }); +} + +/** + * Transforms the query decorator by explicitly specifying the timing based on the + * determined timing. The changes will be added to the specified update recorder. + */ +function recordQueryUsageTransformation( + query: NgQueryDefinition, recorder: UpdateRecorder, timing: QueryTiming, printer: ts.Printer, + sourceFile: ts.SourceFile) { + const queryExpr = query.decorator.node.expression as ts.CallExpression; + const queryArguments = queryExpr.arguments; + const timingPropertyAssignment = ts.createPropertyAssignment( + 'static', timing === QueryTiming.STATIC ? ts.createTrue() : ts.createFalse()); + let newCallText = ''; + + // If the query decorator is already called with two arguments, we need to + // keep the existing options untouched and just add the new property if needed. + if (queryArguments.length === 2) { + const existingOptions = queryArguments[1] as ts.ObjectLiteralExpression; + + // In case the options already contains a property for the "static" flag, we just + // skip this query and leave it untouched. + if (existingOptions.properties.some( + p => !!p.name && getPropertyNameText(p.name) === 'static')) { + return; + } + + const updatedOptions = ts.updateObjectLiteral( + existingOptions, existingOptions.properties.concat(timingPropertyAssignment)); + const updatedCall = ts.updateCall( + queryExpr, queryExpr.expression, queryExpr.typeArguments, + [queryArguments[0], updatedOptions]); + newCallText = printer.printNode(ts.EmitHint.Unspecified, updatedCall, sourceFile); + } else { + const newCall = ts.updateCall( + queryExpr, queryExpr.expression, queryExpr.typeArguments, + [queryArguments[0], ts.createObjectLiteral([timingPropertyAssignment])]); + newCallText = printer.printNode(ts.EmitHint.Unspecified, newCall, sourceFile); + } + + recorder.remove(queryExpr.getStart(), queryExpr.getWidth()); + recorder.insertRight(queryExpr.getStart(), newCallText); +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/class_declaration.ts b/packages/core/schematics/migrations/static-queries/typescript/class_declaration.ts new file mode 100644 index 0000000000..3a835539b6 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/class_declaration.ts @@ -0,0 +1,32 @@ +/** + * @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'; + +/** Determines the base type identifiers of a specified class declaration. */ +export function getBaseTypeIdentifiers(node: ts.ClassDeclaration): ts.Identifier[]|null { + if (!node.heritageClauses) { + return null; + } + + return node.heritageClauses.filter(clause => clause.token === ts.SyntaxKind.ExtendsKeyword) + .reduce((types, clause) => types.concat(clause.types), [] as ts.ExpressionWithTypeArguments[]) + .map(typeExpression => typeExpression.expression) + .filter(ts.isIdentifier); +} + +/** Gets the first found parent class declaration of a given node. */ +export function findParentClassDeclaration(node: ts.Node): ts.ClassDeclaration|null { + while (!ts.isClassDeclaration(node)) { + if (ts.isSourceFile(node)) { + return null; + } + node = node.parent; + } + return node; +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/decorators.ts b/packages/core/schematics/migrations/static-queries/typescript/decorators.ts new file mode 100644 index 0000000000..75a900c2b4 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/decorators.ts @@ -0,0 +1,24 @@ +/** + * @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 {Import, getImportOfIdentifier} from './imports'; + +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)) { + return null; + } + + const identifier = decorator.expression.expression; + return getImportOfIdentifier(typeChecker, identifier); +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/imports.ts b/packages/core/schematics/migrations/static-queries/typescript/imports.ts new file mode 100644 index 0000000000..65142eaefa --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/imports.ts @@ -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 * as ts from 'typescript'; + +export type Import = { + name: string, + importModule: string +}; + +/** Gets import information about the specified identifier by using the Type checker. */ +export function getImportOfIdentifier(typeChecker: ts.TypeChecker, node: ts.Identifier): Import| + null { + const symbol = typeChecker.getSymbolAtLocation(node); + + if (!symbol || !symbol.declarations.length) { + return null; + } + + const decl = symbol.declarations[0]; + + if (!ts.isImportSpecifier(decl)) { + return null; + } + + const importDecl = decl.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 + }; +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/property_name.ts b/packages/core/schematics/migrations/static-queries/typescript/property_name.ts new file mode 100644 index 0000000000..79b4cc56f9 --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/property_name.ts @@ -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 + */ + +import * as ts from 'typescript'; + +/** Type that describes a property name with an obtainable text. */ +type PropertyNameWithText = Exclude; + +/** + * Gets the text of the given property name. Returns null if the property + * name couldn't be determined statically. + */ +export function getPropertyNameText(node: ts.PropertyName): string|null { + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) { + return node.text; + } + return null; +} + +/** Checks whether the given property name has a text. */ +export function hasPropertyNameText(node: ts.PropertyName): node is PropertyNameWithText { + return ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isIdentifier(node); +} diff --git a/packages/core/schematics/migrations/static-queries/typescript/tsconfig.ts b/packages/core/schematics/migrations/static-queries/typescript/tsconfig.ts new file mode 100644 index 0000000000..220448a0ef --- /dev/null +++ b/packages/core/schematics/migrations/static-queries/typescript/tsconfig.ts @@ -0,0 +1,21 @@ +/** + * @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 parseTsconfigFile(tsconfigPath: string, basePath: string): ts.ParsedCommandLine { + const {config} = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + const parseConfigHost = { + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + fileExists: ts.sys.fileExists, + readDirectory: ts.sys.readDirectory, + readFile: ts.sys.readFile, + }; + + return ts.parseJsonConfigFileContent(config, parseConfigHost, basePath, {}); +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel new file mode 100644 index 0000000000..fd1877312e --- /dev/null +++ b/packages/core/schematics/test/BUILD.bazel @@ -0,0 +1,22 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.ts"]), + data = [ + "//packages/core/schematics:migrations.json", + "@npm//shelljs", + ], + deps = [ + "//packages/core/schematics/migrations/static-queries", + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/shelljs", + ], +) + +jasmine_node_test( + name = "test", + deps = [":test_lib"], +) diff --git a/packages/core/schematics/test/project_tsconfig_paths_spec.ts b/packages/core/schematics/test/project_tsconfig_paths_spec.ts new file mode 100644 index 0000000000..b26137eb37 --- /dev/null +++ b/packages/core/schematics/test/project_tsconfig_paths_spec.ts @@ -0,0 +1,47 @@ +/** + * @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 {HostTree} from '@angular-devkit/schematics'; +import {UnitTestTree} from '@angular-devkit/schematics/testing'; +import {getProjectTsConfigPaths} from '../utils/project_tsconfig_paths'; + +describe('project tsconfig paths', () => { + let testTree: UnitTestTree; + + beforeEach(() => { testTree = new UnitTestTree(new HostTree()); }); + + it('should detect build tsconfig path inside of angular.json file', () => { + testTree.create('/my-custom-config.json', ''); + testTree.create('/angular.json', JSON.stringify({ + projects: + {my_name: {architect: {build: {options: {tsConfig: './my-custom-config.json'}}}}} + })); + + expect(getProjectTsConfigPaths(testTree)).toEqual(['./my-custom-config.json']); + }); + + it('should detect test tsconfig path inside of .angular.json file', () => { + testTree.create('/my-test-config.json', ''); + testTree.create('/.angular.json', JSON.stringify({ + projects: + {with_tests: {architect: {test: {options: {tsConfig: './my-test-config.json'}}}}} + })); + + expect(getProjectTsConfigPaths(testTree)).toEqual(['./my-test-config.json']); + }); + + it('should detect common tsconfigs if no workspace config could be found', () => { + testTree.create('/tsconfig.json', ''); + testTree.create('/src/tsconfig.json', ''); + testTree.create('/src/tsconfig.app.json', ''); + + expect(getProjectTsConfigPaths(testTree)).toEqual([ + './tsconfig.json', './src/tsconfig.json', './src/tsconfig.app.json' + ]); + }); +}); diff --git a/packages/core/schematics/test/static_queries_migration_spec.ts b/packages/core/schematics/test/static_queries_migration_spec.ts new file mode 100644 index 0000000000..6ed28cbf39 --- /dev/null +++ b/packages/core/schematics/test/static_queries_migration_spec.ts @@ -0,0 +1,524 @@ +/** + * @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import * as shx from 'shelljs'; + +describe('static-queries migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + beforeEach(() => { + runner = new SchematicTestRunner('test', require.resolve('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile('/tsconfig.json', JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + } + })); + + previousWorkingDir = shx.pwd(); + tmpDirPath = getSystemPath(host.root); + + // Switch into the temporary directory path. This allows us to run + // the schematic against our custom unit test tree. + shx.cd(tmpDirPath); + }); + + afterEach(() => { + shx.cd(previousWorkingDir); + shx.rm('-r', tmpDirPath); + }); + + describe('ViewChild', () => { + createQueryTests('ViewChild'); + + it('should mark view queries used in "ngAfterContentInit" as static', () => { + writeFile('/index.ts', ` + import {Component, ViewChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ViewChild('test') query: any; + + ngAfterContentInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ViewChild('test', { static: true }) query: any;`); + }); + + it('should mark view queries used in "ngAfterContentChecked" as static', () => { + writeFile('/index.ts', ` + import {Component, ViewChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ViewChild('test') query: any; + + ngAfterContentChecked() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ViewChild('test', { static: true }) query: any;`); + }); + }); + + describe('ContentChild', () => { + createQueryTests('ContentChild'); + + it('should not mark content queries used in "ngAfterContentInit" as static', () => { + writeFile('/index.ts', ` + import {Component, ContentChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ContentChild('test') query: any; + + ngAfterContentInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ContentChild('test', { static: false }) query: any;`); + }); + + it('should not mark content queries used in "ngAfterContentChecked" as static', () => { + writeFile('/index.ts', ` + import {Component, ContentChild} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @ContentChild('test') query: any; + + ngAfterContentChecked() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@ContentChild('test', { static: false }) query: any;`); + }); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { runner.runSchematic('migration-v8-static-queries', {}, tree); } + + function createQueryTests(queryType: 'ViewChild' | 'ContentChild') { + it('should mark queries as dynamic', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') unused: any; + @${queryType}('dynamic') dynamic: any; + + onClick() { + this.dynamicQuery.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) unused: any;`); + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('dynamic', { static: false }) dynamic: any`); + }); + + it('should mark queries used in "ngOnInit" as static', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + ngOnInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should keep existing query options when updating timing', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test', { /* test */ read: null }) query: any; + + ngOnInit() { + this.query.classList.add('test'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { /* test */ read: null, static: true }) query: any;`); + }); + + it('should not overwrite existing explicit query timing', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test', {static: /* untouched */ someVal}) query: any; + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', {static: /* untouched */ someVal}) query: any;`); + }); + + it('should detect queries used in deep method chain', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + // We intentionally add this comma for the second parameter in order + // to ensure that the migration does not incorrectly create an invalid + // decorator call with three parameters. e.g. "ViewQuery('test', {...}, )" + @${queryType}('test', ) query: any; + + ngOnInit() { + this.a(); + } + + a() { + this.b(); + } + + b() { + this.c(); + } + + c() { + console.log(this.query); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should properly exit if recursive function is analyzed', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + ngOnInit() { + this.recursive(); + } + + recursive() { + this.recursive(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) query: any;`); + }); + + it('should detect queries used in newly instantiated classes', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + @${queryType}('test') query2: any; + + ngOnInit() { + new A(this); + } + } + + export class A { + constructor(ctx: MyComp) { + ctx.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) query2: any;`); + }); + + it('should detect queries in lifecycle hook with string literal name', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + 'ngOnInit'() { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect static queries within nested inheritance', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + } + + export class A extends MyComp {} + export class B extends A { + + ngOnInit() { + this.query.testFn(); + } + + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect static queries used within input setters', () => { + writeFile('/index.ts', ` + import {Component, Input, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + @Input() + get myVal() { return null; } + set myVal(newVal: any) { + this.query.classList.add('setter'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect inputs defined in metadata', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({ + template: '', + inputs: ["myVal"], + }) + export class MyComp { + @${queryType}('test') query: any; + + // We don't use the input decorator here as we want to verify + // that it properly detects the input through the component metadata. + get myVal() { return null; } + set myVal(newVal: any) { + this.query.classList.add('setter'); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect aliased inputs declared in metadata', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({ + template: '', + inputs: ['prop: publicName'], + }) + export class MyComp { + @${queryType}('test') query: any; + + set prop(val: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should not mark query as static if query is used in non-input setter', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + set myProperty(val: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: false }) query: any;`); + }); + + it('should detect input decorator on setter', () => { + writeFile('/index.ts', ` + import {Input, Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + + get myProperty() { return null; } + + // Usually the decorator is set on the get accessor, but it's also possible + // to declare the input on the setter. This ensures that it is handled properly. + @Input() + set myProperty(val: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should detect setter inputs in derived classes', () => { + writeFile('/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({ + template: '', + inputs: ['childSetter'], + }) + export class MyComp { + protected @${queryType}('test') query: any; + } + + export class B extends MyComp { + set childSetter(newVal: any) { + this.query.test(); + } + } + `); + + runMigration(); + + expect(tree.readContent('/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + + it('should properly detect static query in external derived class', () => { + writeFile('/src/index.ts', ` + import {Component, ${queryType}} from '@angular/core'; + + @Component({template: ''}) + export class MyComp { + @${queryType}('test') query: any; + } + `); + + writeFile('/src/external.ts', ` + import {MyComp} from './index'; + + export class ExternalComp extends MyComp { + ngOnInit() { + this.query.test(); + } + } + `); + + // Move the tsconfig into a subdirectory. This ensures that the update is properly + // recorded for TypeScript projects not at the schematic tree root. + host.sync.rename(normalize('/tsconfig.json'), normalize('/src/tsconfig.json')); + + runMigration(); + + expect(tree.readContent('/src/index.ts')) + .toContain(`@${queryType}('test', { static: true }) query: any;`); + }); + } +}); diff --git a/packages/core/schematics/tsconfig.json b/packages/core/schematics/tsconfig.json new file mode 100644 index 0000000000..6e6c7ac264 --- /dev/null +++ b/packages/core/schematics/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strictNullChecks": true, + "noImplicitReturns": true, + "lib": ["es2015"], + "types": [] + } +} diff --git a/packages/core/schematics/utils/BUILD.bazel b/packages/core/schematics/utils/BUILD.bazel new file mode 100644 index 0000000000..cffc353f4d --- /dev/null +++ b/packages/core/schematics/utils/BUILD.bazel @@ -0,0 +1,12 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "utils", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*_spec.ts"], + ), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = ["//packages/core/schematics:__subpackages__"], + deps = ["@npm//@angular-devkit/schematics"], +) diff --git a/packages/core/schematics/utils/project_tsconfig_paths.ts b/packages/core/schematics/utils/project_tsconfig_paths.ts new file mode 100644 index 0000000000..4623f012af --- /dev/null +++ b/packages/core/schematics/utils/project_tsconfig_paths.ts @@ -0,0 +1,70 @@ +/** + * @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 {Tree} from '@angular-devkit/schematics'; + +/** Name of the default Angular CLI workspace configuration files. */ +const defaultWorkspaceConfigPaths = ['/angular.json', '/.angular.json']; + +/** + * Gets all tsconfig paths from a CLI project by reading the workspace configuration + * and looking for common tsconfig locations. + */ +export function getProjectTsConfigPaths(tree: Tree): string[] { + // Start with some tsconfig paths that are generally used within CLI projects. + const tsconfigPaths = new Set([ + './tsconfig.json', + './src/tsconfig.json', + './src/tsconfig.app.json', + ]); + + // Add any tsconfig directly referenced in a build or test task of the angular.json workspace. + const workspace = getWorkspaceConfigGracefully(tree); + + if (workspace) { + const projects = Object.keys(workspace.projects).map(name => workspace.projects[name]); + for (const project of projects) { + ['build', 'test'].forEach(targetName => { + if (project.targets && project.targets[targetName] && project.targets[targetName].options && + project.targets[targetName].options.tsConfig) { + tsconfigPaths.add(project.targets[targetName].options.tsConfig); + } + + if (project.architect && project.architect[targetName] && + project.architect[targetName].options && + project.architect[targetName].options.tsConfig) { + tsconfigPaths.add(project.architect[targetName].options.tsConfig); + } + }); + } + } + + // Filter out tsconfig files that don't exist in the CLI project. + return Array.from(tsconfigPaths).filter(p => tree.exists(p)); +} + +/** + * Resolve the workspace configuration of the specified tree gracefully. We cannot use the utility + * functions from the default Angular schematics because those might not be present in older + * versions of the CLI. Also it's important to resolve the workspace gracefully because + * the CLI project could be still using `.angular-cli.json` instead of thew new config. + */ +function getWorkspaceConfigGracefully(tree: Tree): any { + const path = defaultWorkspaceConfigPaths.find(filePath => tree.exists(filePath)); + const configBuffer = tree.read(path !); + + if (!path || !configBuffer) { + return null; + } + + try { + return JSON.parse(configBuffer.toString()); + } catch { + return null; + } +}