Skip to content

Commit

Permalink
feat(compiler-cli): lower metadata useValue and data literal fiel…
Browse files Browse the repository at this point in the history
…ds (#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
  • Loading branch information
chuckjaz authored and jasonaden committed Aug 31, 2017
1 parent 2f2d5f3 commit c685cc2
Show file tree
Hide file tree
Showing 8 changed files with 324 additions and 28 deletions.
8 changes: 6 additions & 2 deletions packages/compiler-cli/src/diagnostics/check_types.ts
Expand Up @@ -166,9 +166,13 @@ function diagnosticMessageToString(message: ts.DiagnosticMessageChain | string):
return ts.flattenDiagnosticMessageText(message, '\n');
}

const REWRITE_PREFIX = /^\u0275[0-9]+$/;

function createFactoryInfo(emitter: TypeScriptEmitter, file: GeneratedFile): FactoryInfo {
const {sourceText, context} =
emitter.emitStatementsAndContext(file.srcFileUrl, file.genFileUrl, file.stmts !);
const {sourceText, context} = emitter.emitStatementsAndContext(
file.srcFileUrl, file.genFileUrl, file.stmts !,
/* preamble */ undefined, /* emitSourceMaps */ undefined,
/* referenceFilter */ reference => !!(reference.name && REWRITE_PREFIX.test(reference.name)));
const source = ts.createSourceFile(
file.genFileUrl, sourceText, ts.ScriptTarget.Latest, /* setParentNodes */ true);
return {source, context};
Expand Down
100 changes: 93 additions & 7 deletions packages/compiler-cli/src/transformers/lower_expressions.ts
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {CollectorOptions, MetadataCollector, MetadataValue, ModuleMetadata} from '@angular/tsc-wrapped';
import {CollectorOptions, MetadataCollector, MetadataValue, ModuleMetadata, isMetadataGlobalReferenceExpression} from '@angular/tsc-wrapped';
import * as ts from 'typescript';

export interface LoweringRequest {
Expand Down Expand Up @@ -181,6 +181,30 @@ function shouldLower(node: ts.Node | undefined): boolean {
return true;
}

const REWRITE_PREFIX = '\u0275';

function isPrimitive(value: any): boolean {
return Object(value) !== value;
}

function isRewritten(value: any): boolean {
return isMetadataGlobalReferenceExpression(value) && value.name.startsWith(REWRITE_PREFIX);
}

function isLiteralFieldNamed(node: ts.Node, names: Set<string>): boolean {
if (node.parent && node.parent.kind == ts.SyntaxKind.PropertyAssignment) {
const property = node.parent as ts.PropertyAssignment;
if (property.parent && property.parent.kind == ts.SyntaxKind.ObjectLiteralExpression &&
property.name && property.name.kind == ts.SyntaxKind.Identifier) {
const propertyName = property.name as ts.Identifier;
return names.has(propertyName.text);
}
}
return false;
}

const LOWERABLE_FIELD_NAMES = new Set(['useValue', 'useFactory', 'data']);

export class LowerMetadataCache implements RequestsMap {
private collector: MetadataCollector;
private metadataCache = new Map<string, MetadataAndLoweringRequests>();
Expand Down Expand Up @@ -208,19 +232,41 @@ export class LowerMetadataCache implements RequestsMap {

private getMetadataAndRequests(sourceFile: ts.SourceFile): MetadataAndLoweringRequests {
let identNumber = 0;
const freshIdent = () => '\u0275' + identNumber++;
const freshIdent = () => REWRITE_PREFIX + identNumber++;
const requests = new Map<number, LoweringRequest>();

const isExportedSymbol = (() => {
let exportTable: Set<string>;
return (node: ts.Node) => {
if (node.kind == ts.SyntaxKind.Identifier) {
const ident = node as ts.Identifier;

if (!exportTable) {
exportTable = createExportTableFor(sourceFile);
}
return exportTable.has(ident.text);
}
return false;
};
})();

const replaceNode = (node: ts.Node) => {
const name = freshIdent();
requests.set(node.pos, {name, kind: node.kind, location: node.pos, end: node.end});
return {__symbolic: 'reference', name};
};

const substituteExpression = (value: MetadataValue, node: ts.Node): MetadataValue => {
if ((node.kind === ts.SyntaxKind.ArrowFunction ||
node.kind === ts.SyntaxKind.FunctionExpression) &&
shouldLower(node)) {
return replaceNode(node);
if (!isPrimitive(value) && !isRewritten(value)) {
if ((node.kind === ts.SyntaxKind.ArrowFunction ||
node.kind === ts.SyntaxKind.FunctionExpression) &&
shouldLower(node)) {
return replaceNode(node);
}
if (isLiteralFieldNamed(node, LOWERABLE_FIELD_NAMES) && shouldLower(node) &&
!isExportedSymbol(node)) {
return replaceNode(node);
}
}
return value;
};
Expand All @@ -229,4 +275,44 @@ export class LowerMetadataCache implements RequestsMap {

return {metadata, requests};
}
}
}

function createExportTableFor(sourceFile: ts.SourceFile): Set<string> {
const exportTable = new Set<string>();
// Lazily collect all the exports from the source file
ts.forEachChild(sourceFile, function scan(node) {
switch (node.kind) {
case ts.SyntaxKind.ClassDeclaration:
case ts.SyntaxKind.FunctionDeclaration:
case ts.SyntaxKind.InterfaceDeclaration:
if ((ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) != 0) {
const classDeclaration =
node as(ts.ClassDeclaration | ts.FunctionDeclaration | ts.InterfaceDeclaration);
const name = classDeclaration.name;
if (name) exportTable.add(name.text);
}
break;
case ts.SyntaxKind.VariableStatement:
const variableStatement = node as ts.VariableStatement;
for (const declaration of variableStatement.declarationList.declarations) {
scan(declaration);
}
break;
case ts.SyntaxKind.VariableDeclaration:
const variableDeclaration = node as ts.VariableDeclaration;
if ((ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) != 0 &&
variableDeclaration.name.kind == ts.SyntaxKind.Identifier) {
const name = variableDeclaration.name as ts.Identifier;
exportTable.add(name.text);
}
break;
case ts.SyntaxKind.ExportDeclaration:
const exportDeclaration = node as ts.ExportDeclaration;
const {moduleSpecifier, exportClause} = exportDeclaration;
if (!moduleSpecifier && exportClause) {
exportClause.elements.forEach(spec => { exportTable.add(spec.name.text); });
}
}
});
return exportTable;
}
41 changes: 39 additions & 2 deletions packages/compiler-cli/test/diagnostics/check_types_spec.ts
Expand Up @@ -12,14 +12,15 @@ 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 = {},
tsOptions: ts.CompilerOptions = {}): Diagnostic[] {
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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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'));
}
}
}
74 changes: 74 additions & 0 deletions packages/compiler-cli/test/ngc_spec.ts
Expand Up @@ -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');
});
});
});

0 comments on commit c685cc2

Please sign in to comment.