From 4146650b1a24ead2a8dcd0e68500393f537fd5b1 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 3 Jun 2019 15:40:01 +0200 Subject: [PATCH] feat(store): add ngrx-store-freeze migration (#1901) Closes #1896 --- modules/data/schematics-core/index.ts | 3 + .../data/schematics-core/utility/ast-utils.ts | 58 +++- .../data/schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ modules/effects/schematics-core/index.ts | 3 + .../schematics-core/utility/ast-utils.ts | 58 +++- .../effects/schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ modules/entity/schematics-core/index.ts | 3 + .../schematics-core/utility/ast-utils.ts | 58 +++- .../entity/schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ modules/router-store/schematics-core/index.ts | 3 + .../schematics-core/utility/ast-utils.ts | 58 +++- .../schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ modules/schematics-core/index.ts | 3 + modules/schematics-core/utility/ast-utils.ts | 58 +++- modules/schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ modules/schematics/schematics-core/index.ts | 3 + .../schematics-core/utility/ast-utils.ts | 58 +++- .../schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ .../store-devtools/schematics-core/index.ts | 3 + .../schematics-core/utility/ast-utils.ts | 58 +++- .../schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ .../{8_0_0 => 8_0_0-beta}/index.spec.ts | 2 +- .../migrations/{8_0_0 => 8_0_0-beta}/index.ts | 0 .../store/migrations/8_0_0-rc/index.spec.ts | 266 ++++++++++++++++++ modules/store/migrations/8_0_0-rc/index.ts | 194 +++++++++++++ modules/store/migrations/BUILD | 1 + modules/store/migrations/migration.json | 9 +- modules/store/schematics-core/index.ts | 3 + .../schematics-core/utility/ast-utils.ts | 58 +++- .../store/schematics-core/utility/change.ts | 29 +- .../schematics-core/utility/visit-utils.ts | 33 +++ .../ngrx.io/content/guide/migration/v8.md | 10 + 39 files changed, 1343 insertions(+), 123 deletions(-) create mode 100644 modules/data/schematics-core/utility/visit-utils.ts create mode 100644 modules/effects/schematics-core/utility/visit-utils.ts create mode 100644 modules/entity/schematics-core/utility/visit-utils.ts create mode 100644 modules/router-store/schematics-core/utility/visit-utils.ts create mode 100644 modules/schematics-core/utility/visit-utils.ts create mode 100644 modules/schematics/schematics-core/utility/visit-utils.ts create mode 100644 modules/store-devtools/schematics-core/utility/visit-utils.ts rename modules/store/migrations/{8_0_0 => 8_0_0-beta}/index.spec.ts (98%) rename modules/store/migrations/{8_0_0 => 8_0_0-beta}/index.ts (100%) create mode 100644 modules/store/migrations/8_0_0-rc/index.spec.ts create mode 100644 modules/store/migrations/8_0_0-rc/index.ts create mode 100644 modules/store/schematics-core/utility/visit-utils.ts diff --git a/modules/data/schematics-core/index.ts b/modules/data/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/data/schematics-core/index.ts +++ b/modules/data/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/data/schematics-core/utility/ast-utils.ts b/modules/data/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/data/schematics-core/utility/ast-utils.ts +++ b/modules/data/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/data/schematics-core/utility/change.ts b/modules/data/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/data/schematics-core/utility/change.ts +++ b/modules/data/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/data/schematics-core/utility/visit-utils.ts b/modules/data/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/data/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/modules/effects/schematics-core/index.ts b/modules/effects/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/effects/schematics-core/index.ts +++ b/modules/effects/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/effects/schematics-core/utility/ast-utils.ts b/modules/effects/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/effects/schematics-core/utility/ast-utils.ts +++ b/modules/effects/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/effects/schematics-core/utility/change.ts b/modules/effects/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/effects/schematics-core/utility/change.ts +++ b/modules/effects/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/effects/schematics-core/utility/visit-utils.ts b/modules/effects/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/effects/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/modules/entity/schematics-core/index.ts b/modules/entity/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/entity/schematics-core/index.ts +++ b/modules/entity/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/entity/schematics-core/utility/ast-utils.ts b/modules/entity/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/entity/schematics-core/utility/ast-utils.ts +++ b/modules/entity/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/entity/schematics-core/utility/change.ts b/modules/entity/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/entity/schematics-core/utility/change.ts +++ b/modules/entity/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/entity/schematics-core/utility/visit-utils.ts b/modules/entity/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/entity/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/modules/router-store/schematics-core/index.ts b/modules/router-store/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/router-store/schematics-core/index.ts +++ b/modules/router-store/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/router-store/schematics-core/utility/ast-utils.ts b/modules/router-store/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/router-store/schematics-core/utility/ast-utils.ts +++ b/modules/router-store/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/router-store/schematics-core/utility/change.ts b/modules/router-store/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/router-store/schematics-core/utility/change.ts +++ b/modules/router-store/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/router-store/schematics-core/utility/visit-utils.ts b/modules/router-store/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/router-store/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/modules/schematics-core/index.ts b/modules/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/schematics-core/index.ts +++ b/modules/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/schematics-core/utility/ast-utils.ts b/modules/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/schematics-core/utility/ast-utils.ts +++ b/modules/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/schematics-core/utility/change.ts b/modules/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/schematics-core/utility/change.ts +++ b/modules/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/schematics-core/utility/visit-utils.ts b/modules/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/modules/schematics/schematics-core/index.ts b/modules/schematics/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/schematics/schematics-core/index.ts +++ b/modules/schematics/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/schematics/schematics-core/utility/ast-utils.ts b/modules/schematics/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/schematics/schematics-core/utility/ast-utils.ts +++ b/modules/schematics/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/schematics/schematics-core/utility/change.ts b/modules/schematics/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/schematics/schematics-core/utility/change.ts +++ b/modules/schematics/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/schematics/schematics-core/utility/visit-utils.ts b/modules/schematics/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/schematics/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/modules/store-devtools/schematics-core/index.ts b/modules/store-devtools/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/store-devtools/schematics-core/index.ts +++ b/modules/store-devtools/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/store-devtools/schematics-core/utility/ast-utils.ts b/modules/store-devtools/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/store-devtools/schematics-core/utility/ast-utils.ts +++ b/modules/store-devtools/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/store-devtools/schematics-core/utility/change.ts b/modules/store-devtools/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/store-devtools/schematics-core/utility/change.ts +++ b/modules/store-devtools/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/store-devtools/schematics-core/utility/visit-utils.ts b/modules/store-devtools/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/store-devtools/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/modules/store/migrations/8_0_0/index.spec.ts b/modules/store/migrations/8_0_0-beta/index.spec.ts similarity index 98% rename from modules/store/migrations/8_0_0/index.spec.ts rename to modules/store/migrations/8_0_0-beta/index.spec.ts index 97cee4dd7b..1dd5e627b1 100644 --- a/modules/store/migrations/8_0_0/index.spec.ts +++ b/modules/store/migrations/8_0_0-beta/index.spec.ts @@ -6,7 +6,7 @@ import { import * as path from 'path'; import { createPackageJson } from '../../../schematics-core/testing/create-package'; -describe('Store Migration 8_0_0', () => { +describe('Store Migration 8_0_0 beta', () => { let appTree: UnitTestTree; const collectionPath = path.join(__dirname, '../migration.json'); const pkgName = 'store'; diff --git a/modules/store/migrations/8_0_0/index.ts b/modules/store/migrations/8_0_0-beta/index.ts similarity index 100% rename from modules/store/migrations/8_0_0/index.ts rename to modules/store/migrations/8_0_0-beta/index.ts diff --git a/modules/store/migrations/8_0_0-rc/index.spec.ts b/modules/store/migrations/8_0_0-rc/index.spec.ts new file mode 100644 index 0000000000..93c06f4e3a --- /dev/null +++ b/modules/store/migrations/8_0_0-rc/index.spec.ts @@ -0,0 +1,266 @@ +import { normalize } from '@angular-devkit/core'; +import { EmptyTree } from '@angular-devkit/schematics'; +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; + +describe('Migration to version 8.0.0 rc', () => { + describe('removes the usage of the storeFreeze meta-reducer', () => { + /* tslint:disable */ + const fixtures = [ + { + description: 'removes the ngrx-store-freeze import', + input: `import { storeFreeze } from 'ngrx-store-freeze';`, + expected: ``, + }, + { + description: 'removes the usage of storeFeeze', + input: `import { storeFreeze } from 'ngrx-store-freeze'; + const metaReducers = environment.production ? [] : [storeFreeze]`, + expected: ` + const metaReducers = environment.production ? [] : []`, + }, + { + description: + 'removes the usage of storeFeeze with an appending meta-reducer', + input: `import { storeFreeze } from 'ngrx-store-freeze'; + const metaReducers = environment.production ? [] : [storeFreeze, foo]`, + expected: ` + const metaReducers = environment.production ? [] : [foo]`, + }, + { + description: + 'removes the usage of storeFeeze with an prepending meta-reducer', + input: `import { storeFreeze } from 'ngrx-store-freeze'; + const metaReducers = environment.production ? [] : [foo, storeFreeze]`, + expected: ` + const metaReducers = environment.production ? [] : [foo]`, + }, + { + description: 'removes the usage of storeFeeze in between meta-reducers', + input: `import { storeFreeze } from 'ngrx-store-freeze'; + const metaReducers = environment.production ? [] : [foo, storeFreeze, bar]`, + expected: ` + const metaReducers = environment.production ? [] : [foo, bar]`, + }, + ]; + /* tslint:enable */ + + const reducerPath = normalize('reducers/index.ts'); + + fixtures.forEach(async ({ description, input, expected }) => { + it(description, async () => { + const tree = new UnitTestTree(new EmptyTree()); + // we need a package.json, it will throw otherwise because we're trying to remove ngrx-store-freeze as a dep + tree.create('/package.json', JSON.stringify({})); + tree.create(reducerPath, input); + + const schematicRunner = createSchematicsRunner(); + schematicRunner.runSchematic('ngrx-store-migration-03', {}, tree); + await schematicRunner.engine.executePostTasks().toPromise(); + + const actual = tree.readContent(reducerPath); + expect(actual).toBe(expected); + }); + }); + }); + + describe('StoreModule.forRoot()', () => { + /* tslint:disable */ + const fixtures = [ + { + description: + 'enables strictStateImmutability and strictActionImmutability runtime checks with no store config', + input: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + isStoreFreezeUsed: true, + expected: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true }}), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + }, + { + description: + 'enables strictStateImmutability and strictActionImmutability runtime checks with store config', + input: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { metaReducers }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + isStoreFreezeUsed: true, + expected: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { metaReducers, runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true } }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + }, + { + description: + 'enables strictStateImmutability and strictActionImmutability runtime checks with store config ending with a comma', + input: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { + metaReducers, + }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + isStoreFreezeUsed: true, + expected: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { + metaReducers, runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true }, + }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + }, + { + description: + 'enables strictStateImmutability and strictActionImmutability runtime checks with an empty store config', + input: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + isStoreFreezeUsed: true, + expected: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true } }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + }, + { + description: + 'does not add runtime checks when store-freeze was not used', + input: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { metaReducers }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + isStoreFreezeUsed: false, + expected: ` + @NgModule({ + imports: [ + StoreModule.forRoot(ROOT_REDUCERS, { metaReducers }), + ], + bootstrap: [AppComponent], + }) + export class AppModule {}`, + }, + ]; + /* tslint:enable */ + + const appModulePath = normalize('app.module.ts'); + + fixtures.forEach( + async ({ description, input, isStoreFreezeUsed, expected }) => { + it(description, async () => { + const tree = new UnitTestTree(new EmptyTree()); + // we need a package.json, it will throw otherwise because we're trying to remove ngrx-store-freeze as a dep + tree.create('/package.json', JSON.stringify({})); + if (isStoreFreezeUsed) { + // we need this file to "trigger" the runtime additions + tree.create( + 'reducer.ts', + 'import { storeFreeze } from "ngrx-store-freeze";' + ); + } + tree.create(appModulePath, input); + + const schematicRunner = createSchematicsRunner(); + schematicRunner.runSchematic('ngrx-store-migration-03', {}, tree); + await schematicRunner.engine.executePostTasks().toPromise(); + + const actual = tree.readContent(appModulePath); + expect(actual).toBe(expected); + }); + } + ); + }); + + describe('package.json', () => { + /* tslint:disable */ + const fixtures = [ + { + description: 'removes ngrx-store-freeze as a dependency', + input: JSON.stringify({ + dependencies: { + 'ngrx-store-freeze': '^1.0.0', + }, + }), + }, + { + description: 'removes ngrx-store-freeze as a dev dependency', + input: JSON.stringify({ + devDependencies: { + 'ngrx-store-freeze': '^1.0.0', + }, + }), + }, + { + description: 'does not throw when ngrx-store-freeze is not installed', + input: JSON.stringify({ + dependencies: {}, + devDependencies: {}, + }), + }, + ]; + /* tslint:enable */ + + const packageJsonPath = normalize('package.json'); + + fixtures.forEach(async ({ description, input }) => { + it(description, async () => { + const tree = new UnitTestTree(new EmptyTree()); + tree.create(packageJsonPath, input); + + const schematicRunner = createSchematicsRunner(); + schematicRunner.runSchematic('ngrx-store-migration-03', {}, tree); + await schematicRunner.engine.executePostTasks().toPromise(); + + const actual = tree.readContent(packageJsonPath); + expect(actual).not.toMatch(/ngrx-store-freeze/); + }); + }); + }); +}); + +function createSchematicsRunner() { + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration.json') + ); + + return schematicRunner; +} diff --git a/modules/store/migrations/8_0_0-rc/index.ts b/modules/store/migrations/8_0_0-rc/index.ts new file mode 100644 index 0000000000..5af3b71bbe --- /dev/null +++ b/modules/store/migrations/8_0_0-rc/index.ts @@ -0,0 +1,194 @@ +import * as ts from 'typescript'; +import { + Rule, + chain, + Tree, + SchematicsException, +} from '@angular-devkit/schematics'; +import { + createChangeRecorder, + RemoveChange, + InsertChange, + visitTSSourceFiles, +} from '@ngrx/store/schematics-core'; + +function replaceWithRuntimeChecks(): Rule { + return (tree: Tree) => { + // only add runtime checks when ngrx-store-freeze is used + visitTSSourceFiles(tree, removeUsages) && + visitTSSourceFiles(tree, insertRuntimeChecks); + }; +} + +function removeNgRxStoreFreezePackage(): Rule { + return (tree: Tree) => { + const pkgPath = '/package.json'; + const buffer = tree.read(pkgPath); + if (buffer === null) { + throw new SchematicsException('Could not read package.json'); + } + const content = buffer.toString(); + const pkg = JSON.parse(content); + + if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) { + throw new SchematicsException('Error reading package.json'); + } + + const dependencyCategories = ['dependencies', 'devDependencies']; + + dependencyCategories.forEach(category => { + if (pkg[category] && pkg[category]['ngrx-store-freeze']) { + delete pkg[category]['ngrx-store-freeze']; + } + }); + + tree.overwrite(pkgPath, JSON.stringify(pkg, null, 2)); + return tree; + }; +} + +export default function(): Rule { + return chain([removeNgRxStoreFreezePackage(), replaceWithRuntimeChecks()]); +} + +function removeUsages( + sourceFile: ts.SourceFile, + tree: Tree, + ngrxStoreFreezeIsUsed?: boolean +) { + const importRemovements = findStoreFreezeImportsToRemove(sourceFile); + if (importRemovements.length === 0) { + return ngrxStoreFreezeIsUsed; + } + + const usageReplacements = findStoreFreezeUsagesToRemove(sourceFile); + + const changes = [...importRemovements, ...usageReplacements]; + const recorder = createChangeRecorder(tree, sourceFile.fileName, changes); + tree.commitUpdate(recorder); + + return true; +} + +function insertRuntimeChecks(sourceFile: ts.SourceFile, tree: Tree) { + const runtimeChecksInserts = findRuntimeCHecksToInsert(sourceFile); + const recorder = createChangeRecorder( + tree, + sourceFile.fileName, + runtimeChecksInserts + ); + tree.commitUpdate(recorder); +} + +function findStoreFreezeImportsToRemove(sourceFile: ts.SourceFile) { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter(({ moduleSpecifier }) => { + return ( + moduleSpecifier.getText(sourceFile) === `'ngrx-store-freeze'` || + moduleSpecifier.getText(sourceFile) === `"ngrx-store-freeze"` + ); + }); + + const removements = imports.map( + i => + new RemoveChange(sourceFile.fileName, i.getStart(sourceFile), i.getEnd()) + ); + return removements; +} + +function findStoreFreezeUsagesToRemove(sourceFile: ts.SourceFile) { + let changes: (RemoveChange | InsertChange)[] = []; + ts.forEachChild(sourceFile, crawl); + return changes; + + function crawl(node: ts.Node) { + ts.forEachChild(node, crawl); + + if (!ts.isArrayLiteralExpression(node)) return; + + const elements = node.elements.map(elem => elem.getText(sourceFile)); + const elementsWithoutStoreFreeze = elements.filter( + elemText => elemText !== 'storeFreeze' + ); + + if (elements.length !== elementsWithoutStoreFreeze.length) { + changes.push( + new RemoveChange( + sourceFile.fileName, + node.getStart(sourceFile), + node.getEnd() + ) + ); + changes.push( + new InsertChange( + sourceFile.fileName, + node.getStart(sourceFile), + `[${elementsWithoutStoreFreeze.join(', ')}]` + ) + ); + } + } +} + +function findRuntimeCHecksToInsert(sourceFile: ts.SourceFile) { + let changes: (InsertChange)[] = []; + ts.forEachChild(sourceFile, crawl); + return changes; + + function crawl(node: ts.Node) { + ts.forEachChild(node, crawl); + + if (!ts.isCallExpression(node)) return; + + const expression = node.expression; + if ( + !( + ts.isPropertyAccessExpression(expression) && + expression.expression.getText(sourceFile) === 'StoreModule' && + expression.name.getText(sourceFile) === 'forRoot' + ) + ) { + return; + } + + const runtimeChecks = `runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true }`; + + // covers StoreModule.forRoot(ROOT_REDUCERS) + if (node.arguments.length === 1) { + changes.push( + new InsertChange( + sourceFile.fileName, + node.arguments[0].getEnd(), + `, { ${runtimeChecks}}` + ) + ); + } else if (node.arguments.length === 2) { + const storeConfig = node.arguments[1]; + if (ts.isObjectLiteralExpression(storeConfig)) { + // covers StoreModule.forRoot(ROOT_REDUCERS, {}) + if (storeConfig.properties.length === 0) { + changes.push( + new InsertChange( + sourceFile.fileName, + storeConfig.getEnd() - 1, + `${runtimeChecks} ` + ) + ); + } else { + // covers StoreModule.forRoot(ROOT_REDUCERS, { metaReducers }) + const lastProperty = + storeConfig.properties[storeConfig.properties.length - 1]; + + changes.push( + new InsertChange( + sourceFile.fileName, + lastProperty.getEnd(), + `, ${runtimeChecks}` + ) + ); + } + } + } + } +} diff --git a/modules/store/migrations/BUILD b/modules/store/migrations/BUILD index 4fb1d66805..d8b9d91860 100644 --- a/modules/store/migrations/BUILD +++ b/modules/store/migrations/BUILD @@ -16,6 +16,7 @@ ts_library( module_name = "@ngrx/store/migrations", deps = [ "//modules/store/schematics-core", + "@npm//@angular-devkit/core", "@npm//@angular-devkit/schematics", "@npm//typescript", ], diff --git a/modules/store/migrations/migration.json b/modules/store/migrations/migration.json index 5589b42414..56d3f9c1b9 100644 --- a/modules/store/migrations/migration.json +++ b/modules/store/migrations/migration.json @@ -8,9 +8,14 @@ "factory": "./6_0_0/index" }, "ngrx-store-migration-02": { - "description": "The road to v8", + "description": "The road to v8 beta", "version": "8-beta", - "factory": "./8_0_0/index" + "factory": "./8_0_0-beta/index" + }, + "ngrx-store-migration-03": { + "description": "The road to v8 RC", + "version": "8-rc.1", + "factory": "./8_0_0-rc/index" } } } diff --git a/modules/store/schematics-core/index.ts b/modules/store/schematics-core/index.ts index 7da4fa8c2d..82f2021e08 100644 --- a/modules/store/schematics-core/index.ts +++ b/modules/store/schematics-core/index.ts @@ -21,6 +21,7 @@ export { addExportToModule, addImportToModule, addProviderToModule, + replaceImport, } from './utility/ast-utils'; export { @@ -71,3 +72,5 @@ export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; export { platformVersion } from './utility/libs-version'; + +export { visitTSSourceFiles } from './utility/visit-utils'; diff --git a/modules/store/schematics-core/utility/ast-utils.ts b/modules/store/schematics-core/utility/ast-utils.ts index f6174fc4b1..f5d7d91dd6 100644 --- a/modules/store/schematics-core/utility/ast-utils.ts +++ b/modules/store/schematics-core/utility/ast-utils.ts @@ -7,7 +7,14 @@ * found in the LICENSE file at https://angular.io/license */ import * as ts from 'typescript'; -import { Change, InsertChange, NoopChange } from './change'; +import { + Change, + InsertChange, + NoopChange, + createReplaceChange, + ReplaceChange, +} from './change'; +import { Path } from '@angular-devkit/core'; /** * Find all nodes from the AST in the subtree of node of SyntaxKind kind. @@ -636,3 +643,52 @@ export function insertImport( ts.SyntaxKind.StringLiteral ); } + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): ReplaceChange[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const changes = imports + .map(p => (p.importClause!.namedBindings! as ts.NamedImports).elements) + .reduce((imports, curr) => imports.concat(curr), [] as ts.ImportSpecifier[]) + .map(specifier => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === importAsIs) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + specifier.propertyName.text === importAsIs + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange(sourceFile, path, specifier!, text!, importToBe) + ); + + return changes; +} diff --git a/modules/store/schematics-core/utility/change.ts b/modules/store/schematics-core/utility/change.ts index 5dff73e3b6..51f451b8ba 100644 --- a/modules/store/schematics-core/utility/change.ts +++ b/modules/store/schematics-core/utility/change.ts @@ -77,22 +77,18 @@ export class RemoveChange implements Change { order: number; description: string; - constructor( - public path: string, - private pos: number, - private toRemove: string - ) { - if (pos < 0) { + constructor(public path: string, public pos: number, public end: number) { + if (pos < 0 || end < 0) { throw new Error('Negative positions are invalid'); } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.description = `Removed text in position ${pos} to ${end} of ${path}`; this.order = pos; } apply(host: Host): Promise { return host.read(this.path).then(content => { const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.toRemove.length); + const suffix = content.substring(this.end); // TODO: throw error if toRemove doesn't match removed string. return host.write(this.path, `${prefix}${suffix}`); @@ -109,7 +105,7 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, + public pos: number, public oldText: string, public newText: string ) { @@ -150,14 +146,19 @@ export function createReplaceChange( export function createChangeRecorder( tree: Tree, - path: Path, - changes: ReplaceChange[] + path: string, + changes: Change[] ): UpdateRecorder { const recorder = tree.beginUpdate(path); for (const change of changes) { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } } return recorder; } diff --git a/modules/store/schematics-core/utility/visit-utils.ts b/modules/store/schematics-core/utility/visit-utils.ts new file mode 100644 index 0000000000..fc33b88227 --- /dev/null +++ b/modules/store/schematics-core/utility/visit-utils.ts @@ -0,0 +1,33 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + tree.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + result = visitor(sourceFile, tree, result); + }); + + return result; +} diff --git a/projects/ngrx.io/content/guide/migration/v8.md b/projects/ngrx.io/content/guide/migration/v8.md index a9e3ade2df..6249f2df8f 100644 --- a/projects/ngrx.io/content/guide/migration/v8.md +++ b/projects/ngrx.io/content/guide/migration/v8.md @@ -153,6 +153,16 @@ export const getNews: MemoizedSelector = createSelector( The return type of the `createSelectorFactory` and `createSelector` functions are now a `MemoizedSelector` instead of a `Selector`. +#### Deprecation of ngrx-store-freeze + +
+ +A migration is provided to remove the usage `ngrx-store-freeze`, remove it from the `package.json`, and to enable the built-in runtime checks `strictStateImmutability` and `strictActionImmutability`. + +
+ +With the new built-in runtime checks, the usage of the `ngrx-store-freeze` package has become obsolete. + ### @ngrx/effects #### Resubscribe on Errors