feat(ngcc): migrate services that are missing @Injectable() (#33362)

A class that is provided as Angular service is required to have an
`@Injectable()` decorator so that the compiler generates its injectable
definition for the runtime. Applications are automatically migrated
using the "missing-injectable" schematic, however libraries built for
older version of Angular may not yet satisfy this requirement.

This commit ports the "missing-injectable" schematic to a migration that
is ran when ngcc is processing a library. This ensures that any service
that is provided from an NgModule or Directive/Component will have an
`@Injectable()` decorator.

PR Close #33362
This commit is contained in:
JoostK
2019-10-20 20:40:48 +02:00
committed by Andrew Kushnir
parent 0de2dbfec1
commit 31b9492951
8 changed files with 1082 additions and 137 deletions

View File

@ -42,4 +42,20 @@ export interface MigrationHost {
* @param decorator the decorator to inject.
*/
injectSyntheticDecorator(clazz: ClassDeclaration, decorator: Decorator): void;
/**
* Retrieves all decorators that are associated with the class, including synthetic decorators
* that have been injected before.
* @param clazz the class for which all decorators are retrieved.
* @returns the list of the decorators, or null if the class was not decorated.
*/
getAllDecorators(clazz: ClassDeclaration): Decorator[]|null;
/**
* Determines whether the provided class in within scope of the entry-point that is currently
* being compiled.
* @param clazz the class for which to determine whether it is within the current entry-point.
* @returns true if the file is part of the compiled entry-point, false otherwise.
*/
isInScope(clazz: ClassDeclaration): boolean;
}

View File

@ -0,0 +1,192 @@
/**
* @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 {forwardRefResolver} from '../../../src/ngtsc/annotations';
import {Reference} from '../../../src/ngtsc/imports';
import {ResolvedValue, ResolvedValueMap} from '../../../src/ngtsc/partial_evaluator';
import {ClassDeclaration, Decorator} from '../../../src/ngtsc/reflection';
import {Migration, MigrationHost} from './migration';
import {createInjectableDecorator, isClassDeclaration} from './utils';
/**
* Ensures that classes that are provided as an Angular service in either `NgModule.providers` or
* `Directive.providers`/`Component.viewProviders` are decorated with one of the `@Injectable`,
* `@Directive`, `@Component` or `@Pipe` decorators, adding an `@Injectable()` decorator when none
* are present.
*
* At least one decorator is now mandatory, as otherwise the compiler would not compile an
* injectable definition for the service. This is unlike View Engine, where having just an unrelated
* decorator may have been sufficient for the service to become injectable.
*
* In essence, this migration operates on classes that are themselves an NgModule, Directive or
* Component. Their metadata is statically evaluated so that their "providers"/"viewProviders"
* properties can be analyzed. For any provider that refers to an undecorated class, the class will
* be migrated to have an `@Injectable()` decorator.
*
* This implementation mirrors the "missing-injectable" schematic.
*/
export class MissingInjectableMigration implements Migration {
apply(clazz: ClassDeclaration, host: MigrationHost): ts.Diagnostic|null {
const decorators = host.reflectionHost.getDecoratorsOfDeclaration(clazz);
if (decorators === null) {
return null;
}
for (const decorator of decorators) {
const name = getAngularCoreDecoratorName(decorator);
if (name === 'NgModule') {
migrateNgModuleProviders(decorator, host);
} else if (name === 'Directive') {
migrateDirectiveProviders(decorator, host, /* isComponent */ false);
} else if (name === 'Component') {
migrateDirectiveProviders(decorator, host, /* isComponent */ true);
}
}
return null;
}
}
/**
* Iterates through all `NgModule.providers` and adds the `@Injectable()` decorator to any provider
* that is not otherwise decorated.
*/
function migrateNgModuleProviders(decorator: Decorator, host: MigrationHost): void {
if (decorator.args === null || decorator.args.length !== 1) {
return;
}
const metadata = host.evaluator.evaluate(decorator.args[0], forwardRefResolver);
if (!(metadata instanceof Map)) {
return;
}
migrateProviders(metadata, 'providers', host);
// TODO(alxhub): we should probably also check for `ModuleWithProviders` here.
}
/**
* Iterates through all `Directive.providers` and if `isComponent` is set to true also
* `Component.viewProviders` and adds the `@Injectable()` decorator to any provider that is not
* otherwise decorated.
*/
function migrateDirectiveProviders(
decorator: Decorator, host: MigrationHost, isComponent: boolean): void {
if (decorator.args === null || decorator.args.length !== 1) {
return;
}
const metadata = host.evaluator.evaluate(decorator.args[0], forwardRefResolver);
if (!(metadata instanceof Map)) {
return;
}
migrateProviders(metadata, 'providers', host);
if (isComponent) {
migrateProviders(metadata, 'viewProviders', host);
}
}
/**
* Given an object with decorator metadata, iterates through the list of providers to add
* `@Injectable()` to any provider that is not otherwise decorated.
*/
function migrateProviders(metadata: ResolvedValueMap, field: string, host: MigrationHost): void {
if (!metadata.has(field)) {
return;
}
const providers = metadata.get(field) !;
if (!Array.isArray(providers)) {
return;
}
for (const provider of providers) {
migrateProvider(provider, host);
}
}
/**
* Analyzes a single provider entry and determines the class that is required to have an
* `@Injectable()` decorator.
*/
function migrateProvider(provider: ResolvedValue, host: MigrationHost): void {
if (provider instanceof Map) {
if (!provider.has('provide') || provider.has('useValue') || provider.has('useFactory') ||
provider.has('useExisting')) {
return;
}
if (provider.has('useClass')) {
// {provide: ..., useClass: SomeClass, deps: [...]} does not require a decorator on SomeClass,
// as the provider itself configures 'deps'. Only if 'deps' is missing will this require a
// factory to exist on SomeClass.
if (!provider.has('deps')) {
migrateProviderClass(provider.get('useClass') !, host);
}
} else {
migrateProviderClass(provider.get('provide') !, host);
}
} else if (Array.isArray(provider)) {
for (const v of provider) {
migrateProvider(v, host);
}
} else {
migrateProviderClass(provider, host);
}
}
/**
* Given a provider class, adds the `@Injectable()` decorator if no other relevant Angular decorator
* is present on the class.
*/
function migrateProviderClass(provider: ResolvedValue, host: MigrationHost): void {
// Providers that do not refer to a class cannot be migrated.
if (!(provider instanceof Reference)) {
return;
}
const clazz = provider.node as ts.Declaration;
if (isClassDeclaration(clazz) && host.isInScope(clazz) && needsInjectableDecorator(clazz, host)) {
host.injectSyntheticDecorator(clazz, createInjectableDecorator(clazz));
}
}
const NO_MIGRATE_DECORATORS = new Set(['Injectable', 'Directive', 'Component', 'Pipe']);
/**
* Determines if the given class needs to be decorated with `@Injectable()` based on whether it
* already has an Angular decorator applied.
*/
function needsInjectableDecorator(clazz: ClassDeclaration, host: MigrationHost): boolean {
const decorators = host.getAllDecorators(clazz);
if (decorators === null) {
return true;
}
for (const decorator of decorators) {
const name = getAngularCoreDecoratorName(decorator);
if (name !== null && NO_MIGRATE_DECORATORS.has(name)) {
return false;
}
}
return true;
}
/**
* Determines the original name of a decorator if it is from '@angular/core'. For other decorators,
* null is returned.
*/
export function getAngularCoreDecoratorName(decorator: Decorator): string|null {
if (decorator.import === null || decorator.import.from !== '@angular/core') {
return null;
}
return decorator.import.name;
}

View File

@ -54,6 +54,27 @@ export function createDirectiveDecorator(clazz: ClassDeclaration): Decorator {
};
}
/**
* Create an empty `Injectable` decorator that will be associated with the `clazz`.
*/
export function createInjectableDecorator(clazz: ClassDeclaration): Decorator {
const decoratorType = ts.createIdentifier('Injectable');
const decoratorNode = ts.createObjectLiteral([
ts.createPropertyAssignment('type', decoratorType),
ts.createPropertyAssignment('args', ts.createArrayLiteral([])),
]);
setParentPointers(clazz.getSourceFile(), decoratorNode);
return {
name: 'Injectable',
identifier: decoratorType,
import: {name: 'Injectable', from: '@angular/core'},
node: decoratorNode,
args: [],
};
}
/**
* Ensure that a tree of AST nodes have their parents wired up.
*/