feat(compiler-cli): lower metadata useValue and data literal fields (#18905)

With this commit the compiler will "lower" expressions into exported
variables for values the compiler does not need to know statically
in order to be able to generate a factory. For example:

```
  providers: [{provider: 'token', useValue: calculated()}]
```

produced an error as the expression `calculated()` is not supported
by the compiler because `calculated` is not a
[known function](https://angular.io/guide/metadata#annotationsdecorators)

With this commit this is rewritten, during emit of the .js file, into
something like:

```
export var ɵ0 = calculated();

  ...

  provdiers: [{provider: 'token', useValue: ɵ0}]
```

The compiler then will now generate a reference to the exported `ɵ0`
instead of failing to evaluate `calculated()`.

PR Close #18905
This commit is contained in:
Chuck Jazdzewski
2017-08-23 10:22:17 -07:00
committed by Jason Aden
parent c7e1bda32f
commit 0e64261f26
8 changed files with 324 additions and 28 deletions

View File

@ -12,6 +12,7 @@ import * as ts from 'typescript';
import {TypeChecker} from '../../src/diagnostics/check_types';
import {Diagnostic} from '../../src/transformers/api';
import {LowerMetadataCache} from '../../src/transformers/lower_expressions';
function compile(
rootDirs: MockData, options: AotCompilerOptions = {},
@ -19,7 +20,7 @@ function compile(
const rootDirArr = toMockFileArray(rootDirs);
const scriptNames = rootDirArr.map(entry => entry.fileName).filter(isSource);
const host = new MockCompilerHost(scriptNames, arrayToMockDir(rootDirArr));
const aotHost = new MockAotCompilerHost(host);
const aotHost = new MockAotCompilerHost(host, new LowerMetadataCache({}));
const tsSettings = {...settings, ...tsOptions};
const program = ts.createProgram(host.scriptNames.slice(0), tsSettings, host);
const ngChecker = new TypeChecker(program, tsSettings, host, aotHost, options);
@ -80,6 +81,12 @@ describe('ng type checker', () => {
it('should accept a safe property access of a nullable field reference of a method result',
() => { a('{{getMaybePerson()?.name}}'); });
});
describe('with lowered expressions', () => {
it('should not report lowered expressions as errors', () => {
expectNoDiagnostics(compile([angularFiles, LOWERING_QUICKSTART]));
});
});
});
function appComponentSource(template: string): string {
@ -134,8 +141,38 @@ const QUICKSTART: MockDirectory = {
}
};
const LOWERING_QUICKSTART: MockDirectory = {
quickstart: {
app: {
'app.component.ts': appComponentSource('<h1>Hello {{name}}</h1>'),
'app.module.ts': `
import { NgModule, Component } from '@angular/core';
import { toString } from './utils';
import { AppComponent } from './app.component';
class Foo {}
@Component({
template: '',
providers: [
{provide: 'someToken', useFactory: () => new Foo()}
]
})
export class Bar {}
@NgModule({
declarations: [ AppComponent, Bar ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
`
}
}
};
function expectNoDiagnostics(diagnostics: Diagnostic[]) {
if (diagnostics && diagnostics.length) {
throw new Error(diagnostics.map(d => `${d.span}: ${d.message}`).join('\n'));
}
}
}

View File

@ -848,4 +848,78 @@ describe('ngc transformer command-line', () => {
shouldExist('app/main.js');
});
});
describe('expression lowering', () => {
const shouldExist = (fileName: string) => {
if (!fs.existsSync(path.resolve(basePath, fileName))) {
throw new Error(`Expected ${fileName} to be emitted (basePath: ${basePath})`);
}
};
it('should be able to lower supported expressions', () => {
writeConfig(`{
"extends": "./tsconfig-base.json",
"files": ["module.ts"]
}`);
write('module.ts', `
import {NgModule, InjectionToken} from '@angular/core';
import {AppComponent} from './app';
export interface Info {
route: string;
data: string;
}
export const T1 = new InjectionToken<string>('t1');
export const T2 = new InjectionToken<string>('t2');
export const T3 = new InjectionToken<number>('t3');
export const T4 = new InjectionToken<Info[]>('t4');
enum SomeEnum {
OK,
Cancel
}
function calculateString() {
return 'someValue';
}
const routeLikeData = [{
route: '/home',
data: calculateString()
}];
@NgModule({
declarations: [AppComponent],
providers: [
{ provide: T1, useValue: calculateString() },
{ provide: T2, useFactory: () => 'someValue' },
{ provide: T3, useValue: SomeEnum.OK },
{ provide: T4, useValue: routeLikeData }
]
})
export class MyModule {}
`);
write('app.ts', `
import {Component, Inject} from '@angular/core';
import * as m from './module';
@Component({
selector: 'my-app',
template: ''
})
export class AppComponent {
constructor(
@Inject(m.T1) private t1: string,
@Inject(m.T2) private t2: string,
@Inject(m.T3) private t3: number,
@Inject(m.T4) private t4: m.Info[],
) {}
}
`);
expect(mainSync(['-p', basePath], s => {})).toBe(0);
shouldExist('built/module.js');
});
});
});

View File

@ -6,29 +6,92 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ModuleMetadata} from '@angular/tsc-wrapped';
import * as ts from 'typescript';
import {LoweringRequest, RequestLocationMap, getExpressionLoweringTransformFactory} from '../../src/transformers/lower_expressions';
import {LowerMetadataCache, LoweringRequest, RequestLocationMap, getExpressionLoweringTransformFactory} from '../../src/transformers/lower_expressions';
import {Directory, MockAotContext, MockCompilerHost} from '../mocks';
describe('Expression lowering', () => {
it('should be able to lower a simple expression', () => {
expect(convert('const a = 1 +◊b: 2◊;')).toBe('const b = 2; const a = 1 + b; export { b };');
describe('transform', () => {
it('should be able to lower a simple expression', () => {
expect(convert('const a = 1 +◊b: 2◊;')).toBe('const b = 2; const a = 1 + b; export { b };');
});
it('should be able to lower an expression in a decorator', () => {
expect(convert(`
import {Component} from '@angular/core';
@Component({
provider: [{provide: 'someToken', useFactory:◊l: () => null◊}]
})
class MyClass {}
`)).toContain('const l = () => null; exports.l = l;');
});
});
it('should be able to lower an expression in a decorator', () => {
expect(convert(`
describe('collector', () => {
it('should request a lowering for useValue', () => {
const collected = collect(`
import {Component} from '@angular/core';
enum SomeEnum {
OK,
NotOK
}
@Component({
provider: [{provide: 'someToken', useFactory:◊l: () => null◊}]
provider: [{provide: 'someToken', useValue:◊enum: SomeEnum.OK◊}]
})
class MyClass {}
`)).toContain('const l = () => null; exports.l = l;');
export class MyClass {}
`);
expect(collected.requests.has(collected.annotations[0].start))
.toBeTruthy('did not find the useValue');
});
it('should request a lowering for useFactory', () => {
const collected = collect(`
import {Component} from '@angular/core';
@Component({
provider: [{provide: 'someToken', useFactory:◊lambda: () => null◊}]
})
export class MyClass {}
`);
expect(collected.requests.has(collected.annotations[0].start))
.toBeTruthy('did not find the useFactory');
});
it('should request a lowering for data', () => {
const collected = collect(`
import {Component} from '@angular/core';
enum SomeEnum {
OK,
NotOK
}
@Component({
provider: [{provide: 'someToken', data:◊enum: SomeEnum.OK◊}]
})
export class MyClass {}
`);
expect(collected.requests.has(collected.annotations[0].start))
.toBeTruthy('did not find the data field');
});
});
});
function convert(annotatedSource: string) {
// Helpers
interface Annotation {
start: number;
length: number;
name: string;
}
function getAnnotations(annotatedSource: string):
{unannotatedSource: string, annotations: Annotation[]} {
const annotations: {start: number, length: number, name: string}[] = [];
let adjustment = 0;
const unannotatedSource = annotatedSource.replace(
@ -38,6 +101,13 @@ function convert(annotatedSource: string) {
adjustment -= text.length - source.length;
return source;
});
return {unannotatedSource, annotations};
}
// Transform helpers
function convert(annotatedSource: string) {
const {annotations, unannotatedSource} = getAnnotations(annotatedSource);
const baseFileName = 'someFile';
const moduleName = '/' + baseFileName;
@ -103,3 +173,16 @@ function normalizeResult(result: string): string {
.replace(/^ /g, '')
.replace(/ $/g, '');
}
// Collector helpers
function collect(annotatedSource: string) {
const {annotations, unannotatedSource} = getAnnotations(annotatedSource);
const cache = new LowerMetadataCache({});
const sourceFile = ts.createSourceFile(
'someName.ts', unannotatedSource, ts.ScriptTarget.Latest, /* setParentNodes */ true);
return {
metadata: cache.getMetadata(sourceFile),
requests: cache.getRequests(sourceFile), annotations
};
}