From c79362d2d52e5f66fc85cf911de040993ea632f9 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Tue, 23 Apr 2019 14:04:26 +0200 Subject: [PATCH] feat(data): add schematics and migrations (#1782) --- modules/data/BUILD | 3 + modules/data/migrations/BUILD | 31 + modules/data/migrations/migration.json | 5 + modules/data/package.json | 4 + modules/data/schematics-core/BUILD | 29 + modules/data/schematics-core/index.ts | 73 ++ .../data/schematics-core/utility/ast-utils.ts | 638 ++++++++++++++++++ .../data/schematics-core/utility/change.ts | 163 +++++ .../data/schematics-core/utility/config.ts | 147 ++++ .../schematics-core/utility/find-module.ts | 142 ++++ .../schematics-core/utility/libs-version.ts | 1 + .../schematics-core/utility/ngrx-utils.ts | 258 +++++++ .../data/schematics-core/utility/package.ts | 27 + .../schematics-core/utility/parse-name.ts | 16 + .../data/schematics-core/utility/project.ts | 52 ++ .../data/schematics-core/utility/strings.ts | 147 ++++ .../data/schematics-core/utility/update.ts | 43 ++ modules/data/schematics/BUILD | 35 + modules/data/schematics/collection.json | 10 + modules/data/schematics/ng-add/index.spec.ts | 321 +++++++++ modules/data/schematics/ng-add/index.ts | 304 +++++++++ modules/data/schematics/ng-add/schema.json | 45 ++ modules/data/schematics/ng-add/schema.ts | 8 + .../ngrx.io/content/guide/migration/v8.md | 16 + tsconfig.json | 2 + 25 files changed, 2520 insertions(+) create mode 100644 modules/data/migrations/BUILD create mode 100644 modules/data/migrations/migration.json create mode 100644 modules/data/schematics-core/BUILD create mode 100644 modules/data/schematics-core/index.ts create mode 100644 modules/data/schematics-core/utility/ast-utils.ts create mode 100644 modules/data/schematics-core/utility/change.ts create mode 100644 modules/data/schematics-core/utility/config.ts create mode 100644 modules/data/schematics-core/utility/find-module.ts create mode 100644 modules/data/schematics-core/utility/libs-version.ts create mode 100644 modules/data/schematics-core/utility/ngrx-utils.ts create mode 100644 modules/data/schematics-core/utility/package.ts create mode 100644 modules/data/schematics-core/utility/parse-name.ts create mode 100644 modules/data/schematics-core/utility/project.ts create mode 100644 modules/data/schematics-core/utility/strings.ts create mode 100644 modules/data/schematics-core/utility/update.ts create mode 100644 modules/data/schematics/BUILD create mode 100644 modules/data/schematics/collection.json create mode 100644 modules/data/schematics/ng-add/index.spec.ts create mode 100644 modules/data/schematics/ng-add/index.ts create mode 100644 modules/data/schematics/ng-add/schema.json create mode 100644 modules/data/schematics/ng-add/schema.ts diff --git a/modules/data/BUILD b/modules/data/BUILD index 1aa37a5681..68392b688b 100644 --- a/modules/data/BUILD +++ b/modules/data/BUILD @@ -26,6 +26,9 @@ ng_package( ], entry_point = "modules/data/index.js", packages = [ + "//modules/data/migrations:npm_package", + "//modules/data/schematics:npm_package", + "//modules/data/schematics-core:npm_package", ], deps = [ ":data", diff --git a/modules/data/migrations/BUILD b/modules/data/migrations/BUILD new file mode 100644 index 0000000000..d111ccba8e --- /dev/null +++ b/modules/data/migrations/BUILD @@ -0,0 +1,31 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "npm_package", "ts_library") + +ts_library( + name = "migrations", + srcs = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/testing/*.ts", + "**/*.spec.ts", + ], + ), + module_name = "@ngrx/data/migrations", + deps = [ + "//modules/data/schematics-core", + "@npm//@angular-devkit/schematics", + ], +) + +npm_package( + name = "npm_package", + srcs = [ + ":migration.json", + ], + deps = [ + ":migrations", + ], +) diff --git a/modules/data/migrations/migration.json b/modules/data/migrations/migration.json new file mode 100644 index 0000000000..f3dc02d6e5 --- /dev/null +++ b/modules/data/migrations/migration.json @@ -0,0 +1,5 @@ +{ + "$schema": + "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": {} +} diff --git a/modules/data/package.json b/modules/data/package.json index d84f13cd50..520926c2fc 100644 --- a/modules/data/package.json +++ b/modules/data/package.json @@ -28,5 +28,9 @@ "rxjs": "RXJS_VERSION" }, "schematics": "MODULE_SCHEMATICS_COLLECTION", + "ng-update": { + "packageGroup": "NG_UPDATE_PACKAGE_GROUP", + "migrations": "NG_UPDATE_MIGRATIONS" + }, "sideEffects": false } diff --git a/modules/data/schematics-core/BUILD b/modules/data/schematics-core/BUILD new file mode 100644 index 0000000000..df5a317d2a --- /dev/null +++ b/modules/data/schematics-core/BUILD @@ -0,0 +1,29 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "npm_package", "ts_library") + +ts_library( + name = "schematics-core", + srcs = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/testing/**/*.ts", + "**/*spec.ts", + ], + ), + module_name = "@ngrx/data/schematics-core", + deps = [ + "@npm//@angular-devkit/schematics", + "@npm//typescript", + ], +) + +npm_package( + name = "npm_package", + srcs = [], + deps = [ + ":schematics-core", + ], +) diff --git a/modules/data/schematics-core/index.ts b/modules/data/schematics-core/index.ts new file mode 100644 index 0000000000..7da4fa8c2d --- /dev/null +++ b/modules/data/schematics-core/index.ts @@ -0,0 +1,73 @@ +import { + dasherize, + decamelize, + camelize, + classify, + underscore, + group, + capitalize, + featurePath, +} from './utility/strings'; + +export { + findNodes, + getSourceNodes, + getDecoratorMetadata, + getContentOfKeyLiteral, + insertAfterLastOccurrence, + insertImport, + addBootstrapToModule, + addDeclarationToModule, + addExportToModule, + addImportToModule, + addProviderToModule, +} from './utility/ast-utils'; + +export { + Host, + Change, + NoopChange, + InsertChange, + RemoveChange, + ReplaceChange, + createReplaceChange, + createChangeRecorder, +} from './utility/change'; + +export { AppConfig, getWorkspace, getWorkspacePath } from './utility/config'; + +export { + findModule, + findModuleFromOptions, + buildRelativePath, + ModuleOptions, +} from './utility/find-module'; + +export { + addReducerToState, + addReducerToStateInterface, + addReducerImportToNgModule, + addReducerToActionReducerMap, + omit, +} from './utility/ngrx-utils'; + +export { getProjectPath, getProject, isLib } from './utility/project'; + +export const stringUtils = { + dasherize, + decamelize, + camelize, + classify, + underscore, + group, + capitalize, + featurePath, +}; + +export { updatePackage } from './utility/update'; + +export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/data/schematics-core/utility/ast-utils.ts b/modules/data/schematics-core/utility/ast-utils.ts new file mode 100644 index 0000000000..f6174fc4b1 --- /dev/null +++ b/modules/data/schematics-core/utility/ast-utils.ts @@ -0,0 +1,638 @@ +/* istanbul ignore file */ +/** + * @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 { Change, InsertChange, NoopChange } from './change'; + +/** + * Find all nodes from the AST in the subtree of node of SyntaxKind kind. + * @param node + * @param kind + * @param max The maximum number of items to return. + * @return all nodes of kind, or [] if none is found + */ +export function findNodes( + node: ts.Node, + kind: ts.SyntaxKind, + max = Infinity +): ts.Node[] { + if (!node || max == 0) { + return []; + } + + const arr: ts.Node[] = []; + if (node.kind === kind) { + arr.push(node); + max--; + } + if (max > 0) { + for (const child of node.getChildren()) { + findNodes(child, kind, max).forEach(node => { + if (max > 0) { + arr.push(node); + } + max--; + }); + + if (max <= 0) { + break; + } + } + } + + return arr; +} + +/** + * Get all the nodes from a source. + * @param sourceFile The source file object. + * @returns {Observable} An observable of all the nodes in the source. + */ +export function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] { + const nodes: ts.Node[] = [sourceFile]; + const result = []; + + while (nodes.length > 0) { + const node = nodes.shift(); + + if (node) { + result.push(node); + if (node.getChildCount(sourceFile) >= 0) { + nodes.unshift(...node.getChildren()); + } + } + } + + return result; +} + +/** + * Helper for sorting nodes. + * @return function to sort nodes in increasing order of position in sourceFile + */ +function nodesByPosition(first: ts.Node, second: ts.Node): number { + return first.pos - second.pos; +} + +/** + * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` + * or after the last of occurence of `syntaxKind` if the last occurence is a sub child + * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. + * + * @param nodes insert after the last occurence of nodes + * @param toInsert string to insert + * @param file file to insert changes into + * @param fallbackPos position to insert if toInsert happens to be the first occurence + * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after + * @return Change instance + * @throw Error if toInsert is first occurence but fall back is not set + */ +export function insertAfterLastOccurrence( + nodes: ts.Node[], + toInsert: string, + file: string, + fallbackPos: number, + syntaxKind?: ts.SyntaxKind +): Change { + let lastItem = nodes.sort(nodesByPosition).pop(); + if (!lastItem) { + throw new Error(); + } + if (syntaxKind) { + lastItem = findNodes(lastItem, syntaxKind) + .sort(nodesByPosition) + .pop(); + } + if (!lastItem && fallbackPos == undefined) { + throw new Error( + `tried to insert ${toInsert} as first occurence with no fallback position` + ); + } + const lastItemPosition: number = lastItem ? lastItem.end : fallbackPos; + + return new InsertChange(file, lastItemPosition, toInsert); +} + +export function getContentOfKeyLiteral( + _source: ts.SourceFile, + node: ts.Node +): string | null { + if (node.kind == ts.SyntaxKind.Identifier) { + return (node as ts.Identifier).text; + } else if (node.kind == ts.SyntaxKind.StringLiteral) { + return (node as ts.StringLiteral).text; + } else { + return null; + } +} + +function _angularImportsFromNode( + node: ts.ImportDeclaration, + _sourceFile: ts.SourceFile +): { [name: string]: string } { + const ms = node.moduleSpecifier; + let modulePath: string; + switch (ms.kind) { + case ts.SyntaxKind.StringLiteral: + modulePath = (ms as ts.StringLiteral).text; + break; + default: + return {}; + } + + if (!modulePath.startsWith('@angular/')) { + return {}; + } + + if (node.importClause) { + if (node.importClause.name) { + // This is of the form `import Name from 'path'`. Ignore. + return {}; + } else if (node.importClause.namedBindings) { + const nb = node.importClause.namedBindings; + if (nb.kind == ts.SyntaxKind.NamespaceImport) { + // This is of the form `import * as name from 'path'`. Return `name.`. + return { + [(nb as ts.NamespaceImport).name.text + '.']: modulePath, + }; + } else { + // This is of the form `import {a,b,c} from 'path'` + const namedImports = nb as ts.NamedImports; + + return namedImports.elements + .map( + (is: ts.ImportSpecifier) => + is.propertyName ? is.propertyName.text : is.name.text + ) + .reduce((acc: { [name: string]: string }, curr: string) => { + acc[curr] = modulePath; + + return acc; + }, {}); + } + } + + return {}; + } else { + // This is of the form `import 'path';`. Nothing to do. + return {}; + } +} + +export function getDecoratorMetadata( + source: ts.SourceFile, + identifier: string, + module: string +): ts.Node[] { + const angularImports: { [name: string]: string } = findNodes( + source, + ts.SyntaxKind.ImportDeclaration + ) + .map(node => _angularImportsFromNode(node as ts.ImportDeclaration, source)) + .reduce( + ( + acc: { [name: string]: string }, + current: { [name: string]: string } + ) => { + for (const key of Object.keys(current)) { + acc[key] = current[key]; + } + + return acc; + }, + {} + ); + + return getSourceNodes(source) + .filter(node => { + return ( + node.kind == ts.SyntaxKind.Decorator && + (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression + ); + }) + .map(node => (node as ts.Decorator).expression as ts.CallExpression) + .filter(expr => { + if (expr.expression.kind == ts.SyntaxKind.Identifier) { + const id = expr.expression as ts.Identifier; + + return ( + id.getFullText(source) == identifier && + angularImports[id.getFullText(source)] === module + ); + } else if ( + expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression + ) { + // This covers foo.NgModule when importing * as foo. + const paExpr = expr.expression as ts.PropertyAccessExpression; + // If the left expression is not an identifier, just give up at that point. + if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { + return false; + } + + const id = paExpr.name.text; + const moduleId = (paExpr.expression as ts.Identifier).getText(source); + + return id === identifier && angularImports[moduleId + '.'] === module; + } + + return false; + }) + .filter( + expr => + expr.arguments[0] && + expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression + ) + .map(expr => expr.arguments[0] as ts.ObjectLiteralExpression); +} + +function _addSymbolToNgModuleMetadata( + source: ts.SourceFile, + ngModulePath: string, + metadataField: string, + symbolName: string, + importPath: string +): Change[] { + const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core'); + let node: any = nodes[0]; // tslint:disable-line:no-any + + // Find the decorator declaration. + if (!node) { + return []; + } + + // Get all the children property assignment of object literals. + const matchingProperties: ts.ObjectLiteralElement[] = (node as ts.ObjectLiteralExpression).properties + .filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment) + // Filter out every fields that's not "metadataField". Also handles string literals + // (but not expressions). + .filter((prop: any) => { + const name = prop.name; + switch (name.kind) { + case ts.SyntaxKind.Identifier: + return (name as ts.Identifier).getText(source) == metadataField; + case ts.SyntaxKind.StringLiteral: + return (name as ts.StringLiteral).text == metadataField; + } + + return false; + }); + + // Get the last node of the array literal. + if (!matchingProperties) { + return []; + } + if (matchingProperties.length == 0) { + // We haven't found the field in the metadata declaration. Insert a new field. + const expr = node as ts.ObjectLiteralExpression; + let position: number; + let toInsert: string; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n\s*/); + if (matches.length > 0) { + toInsert = `,${matches[0]}${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + const newMetadataProperty = new InsertChange( + ngModulePath, + position, + toInsert + ); + const newMetadataImport = insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ''), + importPath + ); + + return [newMetadataProperty, newMetadataImport]; + } + + const assignment = matchingProperties[0] as ts.PropertyAssignment; + + // If it's not an array, nothing we can do really. + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return []; + } + + const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression; + if (arrLiteral.elements.length == 0) { + // Forward the property. + node = arrLiteral; + } else { + node = arrLiteral.elements; + } + + if (!node) { + console.log( + 'No app module found. Please add your new class to your component.' + ); + + return []; + } + + if (Array.isArray(node)) { + const nodeArray = (node as {}) as Array; + const symbolsArray = nodeArray.map(node => node.getText()); + if (symbolsArray.includes(symbolName)) { + return []; + } + + node = node[node.length - 1]; + + const effectsModule = nodeArray.find( + node => + (node.getText().includes('EffectsModule.forRoot') && + symbolName.includes('EffectsModule.forRoot')) || + (node.getText().includes('EffectsModule.forFeature') && + symbolName.includes('EffectsModule.forFeature')) + ); + + if (effectsModule && symbolName.includes('EffectsModule')) { + const effectsArgs = (effectsModule as any).arguments.shift(); + + if ( + effectsArgs && + effectsArgs.kind === ts.SyntaxKind.ArrayLiteralExpression + ) { + const effectsElements = (effectsArgs as ts.ArrayLiteralExpression) + .elements; + const [, effectsSymbol] = (symbolName).match(/\[(.*)\]/); + + let epos; + if (effectsElements.length === 0) { + epos = effectsArgs.getStart() + 1; + return [new InsertChange(ngModulePath, epos, effectsSymbol)]; + } else { + const lastEffect = effectsElements[ + effectsElements.length - 1 + ] as ts.Expression; + epos = lastEffect.getEnd(); + // Get the indentation of the last element, if any. + const text: any = lastEffect.getFullText(source); + + let effectInsert: string; + if (text.match('^\r?\r?\n')) { + effectInsert = `,${text.match(/^\r?\n\s+/)[0]}${effectsSymbol}`; + } else { + effectInsert = `, ${effectsSymbol}`; + } + + return [new InsertChange(ngModulePath, epos, effectInsert)]; + } + } else { + return []; + } + } + } + + let toInsert: string; + let position = node.getEnd(); + if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { + // We haven't found the field in the metadata declaration. Insert a new + // field. + const expr = node as ts.ObjectLiteralExpression; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match('^\r?\r?\n')) { + toInsert = `,${ + text.match(/^\r?\n\s+/)[0] + }${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + } else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { + // We found the field but it's empty. Insert it just before the `]`. + position--; + toInsert = `${symbolName}`; + } else { + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match(/^\r?\n/)) { + toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${symbolName}`; + } else { + toInsert = `, ${symbolName}`; + } + } + const insert = new InsertChange(ngModulePath, position, toInsert); + const importInsert: Change = insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ''), + importPath + ); + + return [insert, importInsert]; +} + +/** + * Custom function to insert a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +export function addDeclarationToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'declarations', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +export function addImportToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'imports', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert a provider into NgModule. It also imports it. + */ +export function addProviderToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'providers', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addExportToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'exports', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addBootstrapToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'bootstrap', + classifiedName, + importPath + ); +} + +/** + * Add Import `import { symbolName } from fileName` if the import doesn't exit + * already. Assumes fileToEdit can be resolved and accessed. + * @param fileToEdit (file we want to add import to) + * @param symbolName (item to import) + * @param fileName (path to the file) + * @param isDefault (if true, import follows style for importing default exports) + * @return Change + */ + +export function insertImport( + source: ts.SourceFile, + fileToEdit: string, + symbolName: string, + fileName: string, + isDefault = false +): Change { + const rootNode = source; + const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + + // get nodes that map to import statements from the file fileName + const relevantImports = allImports.filter(node => { + // StringLiteral of the ImportDeclaration is the import file (fileName in this case). + const importFiles = node + .getChildren() + .filter(child => child.kind === ts.SyntaxKind.StringLiteral) + .map(n => (n as ts.StringLiteral).text); + + return importFiles.filter(file => file === fileName).length === 1; + }); + + if (relevantImports.length > 0) { + let importsAsterisk = false; + // imports from import file + const imports: ts.Node[] = []; + relevantImports.forEach(n => { + Array.prototype.push.apply( + imports, + findNodes(n, ts.SyntaxKind.Identifier) + ); + if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { + importsAsterisk = true; + } + }); + + // if imports * from fileName, don't add symbolName + if (importsAsterisk) { + return new NoopChange(); + } + + const importTextNodes = imports.filter( + n => (n as ts.Identifier).text === symbolName + ); + + // insert import if it's not there + if (importTextNodes.length === 0) { + const fallbackPos = + findNodes( + relevantImports[0], + ts.SyntaxKind.CloseBraceToken + )[0].getStart() || + findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart(); + + return insertAfterLastOccurrence( + imports, + `, ${symbolName}`, + fileToEdit, + fallbackPos + ); + } + + return new NoopChange(); + } + + // no such import declaration exists + const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral).filter( + n => n.getText() === 'use strict' + ); + let fallbackPos = 0; + if (useStrict.length > 0) { + fallbackPos = useStrict[0].end; + } + const open = isDefault ? '' : '{ '; + const close = isDefault ? '' : ' }'; + // if there are no imports or 'use strict' statement, insert import at beginning of file + const insertAtBeginning = allImports.length === 0 && useStrict.length === 0; + const separator = insertAtBeginning ? '' : ';\n'; + const toInsert = + `${separator}import ${open}${symbolName}${close}` + + ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`; + + return insertAfterLastOccurrence( + allImports, + toInsert, + fileToEdit, + fallbackPos, + ts.SyntaxKind.StringLiteral + ); +} diff --git a/modules/data/schematics-core/utility/change.ts b/modules/data/schematics-core/utility/change.ts new file mode 100644 index 0000000000..5dff73e3b6 --- /dev/null +++ b/modules/data/schematics-core/utility/change.ts @@ -0,0 +1,163 @@ +import * as ts from 'typescript'; +import { Tree, UpdateRecorder } from '@angular-devkit/schematics'; +import { Path } from '@angular-devkit/core'; + +/* istanbul ignore file */ +/** + * @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 + */ +export interface Host { + write(path: string, content: string): Promise; + read(path: string): Promise; +} + +export interface Change { + apply(host: Host): Promise; + + // The file this change should be applied to. Some changes might not apply to + // a file (maybe the config). + readonly path: string | null; + + // The order this change should be applied. Normally the position inside the file. + // Changes are applied from the bottom of a file to the top. + readonly order: number; + + // The description of this change. This will be outputted in a dry or verbose run. + readonly description: string; +} + +/** + * An operation that does nothing. + */ +export class NoopChange implements Change { + description = 'No operation.'; + order = Infinity; + path = null; + apply() { + return Promise.resolve(); + } +} + +/** + * Will add text to the source code. + */ +export class InsertChange implements Change { + order: number; + description: string; + + constructor(public path: string, public pos: number, public toAdd: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; + this.order = pos; + } + + /** + * This method does not insert spaces if there is none in the original string. + */ + apply(host: Host) { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos); + + return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); + }); + } +} + +/** + * Will remove text from the source code. + */ +export class RemoveChange implements Change { + order: number; + description: string; + + constructor( + public path: string, + private pos: number, + private toRemove: string + ) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Removed ${toRemove} into position ${pos} 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); + + // TODO: throw error if toRemove doesn't match removed string. + return host.write(this.path, `${prefix}${suffix}`); + }); + } +} + +/** + * Will replace text from the source code. + */ +export class ReplaceChange implements Change { + order: number; + description: string; + + constructor( + public path: string, + private pos: number, + public oldText: string, + public newText: string + ) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; + 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.oldText.length); + const text = content.substring(this.pos, this.pos + this.oldText.length); + + if (text !== this.oldText) { + return Promise.reject( + new Error(`Invalid replace: "${text}" != "${this.oldText}".`) + ); + } + + // TODO: throw error if oldText doesn't match removed string. + return host.write(this.path, `${prefix}${this.newText}${suffix}`); + }); + } +} + +export function createReplaceChange( + sourceFile: ts.SourceFile, + path: Path, + node: ts.Node, + oldText: string, + newText: string +): ReplaceChange { + return new ReplaceChange(path, node.getStart(sourceFile), oldText, newText); +} + +export function createChangeRecorder( + tree: Tree, + path: Path, + changes: ReplaceChange[] +): 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); + } + return recorder; +} diff --git a/modules/data/schematics-core/utility/config.ts b/modules/data/schematics-core/utility/config.ts new file mode 100644 index 0000000000..c9d306e0a5 --- /dev/null +++ b/modules/data/schematics-core/utility/config.ts @@ -0,0 +1,147 @@ +import { SchematicsException, Tree } from '@angular-devkit/schematics'; +import { experimental } from '@angular-devkit/core'; + +// The interfaces below are generated from the Angular CLI configuration schema +// https://github.com/angular/angular-cli/blob/master/packages/@angular/cli/lib/config/schema.json +export interface AppConfig { + /** + * Name of the app. + */ + name?: string; + /** + * Directory where app files are placed. + */ + appRoot?: string; + /** + * The root directory of the app. + */ + root?: string; + /** + * The output directory for build results. + */ + outDir?: string; + /** + * List of application assets. + */ + assets?: ( + | string + | { + /** + * The pattern to match. + */ + glob?: string; + /** + * The dir to search within. + */ + input?: string; + /** + * The output path (relative to the outDir). + */ + output?: string; + })[]; + /** + * URL where files will be deployed. + */ + deployUrl?: string; + /** + * Base url for the application being built. + */ + baseHref?: string; + /** + * The runtime platform of the app. + */ + platform?: 'browser' | 'server'; + /** + * The name of the start HTML file. + */ + index?: string; + /** + * The name of the main entry-point file. + */ + main?: string; + /** + * The name of the polyfills file. + */ + polyfills?: string; + /** + * The name of the test entry-point file. + */ + test?: string; + /** + * The name of the TypeScript configuration file. + */ + tsconfig?: string; + /** + * The name of the TypeScript configuration file for unit tests. + */ + testTsconfig?: string; + /** + * The prefix to apply to generated selectors. + */ + prefix?: string; + /** + * Experimental support for a service worker from @angular/service-worker. + */ + serviceWorker?: boolean; + /** + * Global styles to be included in the build. + */ + styles?: ( + | string + | { + input?: string; + [name: string]: any; // tslint:disable-line:no-any + })[]; + /** + * Options to pass to style preprocessors + */ + stylePreprocessorOptions?: { + /** + * Paths to include. Paths will be resolved to project root. + */ + includePaths?: string[]; + }; + /** + * Global scripts to be included in the build. + */ + scripts?: ( + | string + | { + input: string; + [name: string]: any; // tslint:disable-line:no-any + })[]; + /** + * Source file for environment config. + */ + environmentSource?: string; + /** + * Name and corresponding file for environment config. + */ + environments?: { + [name: string]: any; // tslint:disable-line:no-any + }; + appShell?: { + app: string; + route: string; + }; +} + +export type WorkspaceSchema = experimental.workspace.WorkspaceSchema; + +export function getWorkspacePath(host: Tree): string { + const possibleFiles = ['/angular.json', '/.angular.json']; + const path = possibleFiles.filter(path => host.exists(path))[0]; + + return path; +} + +export function getWorkspace(host: Tree): WorkspaceSchema { + const path = getWorkspacePath(host); + const configBuffer = host.read(path); + if (configBuffer === null) { + throw new SchematicsException(`Could not find (${path})`); + } + const config = configBuffer.toString(); + + return JSON.parse(config); +} diff --git a/modules/data/schematics-core/utility/find-module.ts b/modules/data/schematics-core/utility/find-module.ts new file mode 100644 index 0000000000..2e8379f877 --- /dev/null +++ b/modules/data/schematics-core/utility/find-module.ts @@ -0,0 +1,142 @@ +/** + * @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 { + Path, + join, + normalize, + relative, + strings, + basename, + extname, + dirname, +} from '@angular-devkit/core'; +import { DirEntry, Tree } from '@angular-devkit/schematics'; + +export interface ModuleOptions { + module?: string; + name: string; + flat?: boolean; + path?: string; + skipImport?: boolean; +} + +/** + * Find the module referred by a set of options passed to the schematics. + */ +export function findModuleFromOptions( + host: Tree, + options: ModuleOptions +): Path | undefined { + if (options.hasOwnProperty('skipImport') && options.skipImport) { + return undefined; + } + + if (!options.module) { + const pathToCheck = + (options.path || '') + + (options.flat ? '' : '/' + strings.dasherize(options.name)); + + return normalize(findModule(host, pathToCheck)); + } else { + const modulePath = normalize('/' + options.path + '/' + options.module); + const moduleBaseName = normalize(modulePath) + .split('/') + .pop(); + + if (host.exists(modulePath)) { + return normalize(modulePath); + } else if (host.exists(modulePath + '.ts')) { + return normalize(modulePath + '.ts'); + } else if (host.exists(modulePath + '.module.ts')) { + return normalize(modulePath + '.module.ts'); + } else if (host.exists(modulePath + '/' + moduleBaseName + '.module.ts')) { + return normalize(modulePath + '/' + moduleBaseName + '.module.ts'); + } else { + throw new Error(`Specified module path ${modulePath} does not exist`); + } + } +} + +/** + * Function to find the "closest" module to a generated file's path. + */ +export function findModule(host: Tree, generateDir: string): Path { + let dir: DirEntry | null = host.getDir('/' + generateDir); + + const moduleRe = /\.module\.ts$/; + const routingModuleRe = /-routing\.module\.ts/; + + while (dir) { + const matches = dir.subfiles.filter( + p => moduleRe.test(p) && !routingModuleRe.test(p) + ); + + if (matches.length == 1) { + return join(dir.path, matches[0]); + } else if (matches.length > 1) { + throw new Error( + 'More than one module matches. Use skip-import option to skip importing ' + + 'the component into the closest module.' + ); + } + + dir = dir.parent; + } + + throw new Error( + 'Could not find an NgModule. Use the skip-import ' + + 'option to skip importing in NgModule.' + ); +} + +/** + * Build a relative path from one file path to another file path. + */ +export function buildRelativePath(from: string, to: string): string { + const { + path: fromPath, + filename: fromFileName, + directory: fromDirectory, + } = parsePath(from); + const { + path: toPath, + filename: toFileName, + directory: toDirectory, + } = parsePath(to); + const relativePath = relative(fromDirectory, toDirectory); + const fixedRelativePath = relativePath.startsWith('.') + ? relativePath + : `./${relativePath}`; + + return !toFileName || toFileName === 'index.ts' + ? fixedRelativePath + : `${ + fixedRelativePath.endsWith('/') + ? fixedRelativePath + : fixedRelativePath + '/' + }${convertToTypeScriptFileName(toFileName)}`; +} + +function parsePath(path: string) { + const pathNormalized = normalize(path) as Path; + const filename = extname(pathNormalized) ? basename(pathNormalized) : ''; + const directory = filename ? dirname(pathNormalized) : pathNormalized; + return { + path: pathNormalized, + filename, + directory, + }; +} +/** + * Strips the typescript extension and clears index filenames + * foo.ts -> foo + * index.ts -> empty + */ +function convertToTypeScriptFileName(filename: string | undefined) { + return filename ? filename.replace(/(\.ts)|(index\.ts)$/, '') : ''; +} diff --git a/modules/data/schematics-core/utility/libs-version.ts b/modules/data/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..71cc37e7bf --- /dev/null +++ b/modules/data/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^8.0.0-beta.0'; diff --git a/modules/data/schematics-core/utility/ngrx-utils.ts b/modules/data/schematics-core/utility/ngrx-utils.ts new file mode 100644 index 0000000000..d856f79346 --- /dev/null +++ b/modules/data/schematics-core/utility/ngrx-utils.ts @@ -0,0 +1,258 @@ +import * as ts from 'typescript'; +import * as stringUtils from './strings'; +import { InsertChange, Change, NoopChange } from './change'; +import { Tree, SchematicsException, Rule } from '@angular-devkit/schematics'; +import { normalize } from '@angular-devkit/core'; +import { buildRelativePath } from './find-module'; +import { addImportToModule, insertImport } from './ast-utils'; + +export function addReducerToState(options: any): Rule { + return (host: Tree) => { + if (!options.reducers) { + return host; + } + + const reducersPath = normalize(`/${options.path}/${options.reducers}`); + + if (!host.exists(reducersPath)) { + throw new Error(`Specified reducers path ${reducersPath} does not exist`); + } + + const text = host.read(reducersPath); + if (text === null) { + throw new SchematicsException(`File ${reducersPath} does not exist.`); + } + + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + reducersPath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const reducerPath = + `/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + (options.group ? 'reducers/' : '') + + stringUtils.dasherize(options.name) + + '.reducer'; + + const relativePath = buildRelativePath(reducersPath, reducerPath); + const reducerImport = insertImport( + source, + reducersPath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + + const stateInterfaceInsert = addReducerToStateInterface( + source, + reducersPath, + options + ); + const reducerMapInsert = addReducerToActionReducerMap( + source, + reducersPath, + options + ); + + const changes = [reducerImport, stateInterfaceInsert, reducerMapInsert]; + const recorder = host.beginUpdate(reducersPath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +/** + * Insert the reducer into the first defined top level interface + */ +export function addReducerToStateInterface( + source: ts.SourceFile, + reducersPath: string, + options: { name: string; plural: boolean } +): Change { + const stateInterface = source.statements.find( + stm => stm.kind === ts.SyntaxKind.InterfaceDeclaration + ); + let node = stateInterface as ts.Statement; + + if (!node) { + return new NoopChange(); + } + + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + + const keyInsert = + state + ': from' + stringUtils.classify(options.name) + '.State;'; + const expr = node as any; + let position; + let toInsert; + + if (expr.members.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.members[expr.members.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches!.length > 0) { + toInsert = `${matches![1]}${keyInsert}\n`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Insert the reducer into the ActionReducerMap + */ +export function addReducerToActionReducerMap( + source: ts.SourceFile, + reducersPath: string, + options: { name: string; plural: boolean } +): Change { + let initializer: any; + const actionReducerMap: any = source.statements + .filter(stm => stm.kind === ts.SyntaxKind.VariableStatement) + .filter((stm: any) => !!stm.declarationList) + .map((stm: any) => { + const { + declarations, + }: { + declarations: ts.SyntaxKind.VariableDeclarationList[]; + } = stm.declarationList; + const variable: any = declarations.find( + (decl: any) => decl.kind === ts.SyntaxKind.VariableDeclaration + ); + const type = variable ? variable.type : {}; + + return { initializer: variable.initializer, type }; + }) + .find(({ type }) => type.typeName.text === 'ActionReducerMap'); + + if (!actionReducerMap || !actionReducerMap.initializer) { + return new NoopChange(); + } + + let node = actionReducerMap.initializer; + + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + + const keyInsert = + state + ': from' + stringUtils.classify(options.name) + '.reducer,'; + const expr = node as any; + let position; + let toInsert; + + if (expr.properties.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches.length > 0) { + toInsert = `\n${matches![1]}${keyInsert}`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Add reducer feature to NgModule + */ +export function addReducerImportToNgModule(options: any): Rule { + return (host: Tree) => { + if (!options.module) { + return host; + } + + const modulePath = options.module; + if (!host.exists(options.module)) { + throw new Error(`Specified module path ${modulePath} does not exist`); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const commonImports = [ + insertImport(source, modulePath, 'StoreModule', '@ngrx/store'), + ]; + + const reducerPath = + `/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + (options.group ? 'reducers/' : '') + + stringUtils.dasherize(options.name) + + '.reducer'; + const relativePath = buildRelativePath(modulePath, reducerPath); + const reducerImport = insertImport( + source, + modulePath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + const [storeNgModuleImport] = addImportToModule( + source, + modulePath, + `StoreModule.forFeature('${stringUtils.camelize( + options.name + )}', from${stringUtils.classify(options.name)}.reducer)`, + relativePath + ); + const changes = [...commonImports, reducerImport, storeNgModuleImport]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +export function omit( + object: T, + keyToRemove: keyof T +): Partial { + return Object.keys(object) + .filter(key => key !== keyToRemove) + .reduce((result, key) => Object.assign(result, { [key]: object[key] }), {}); +} diff --git a/modules/data/schematics-core/utility/package.ts b/modules/data/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/data/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/data/schematics-core/utility/parse-name.ts b/modules/data/schematics-core/utility/parse-name.ts new file mode 100644 index 0000000000..a48f56b8ca --- /dev/null +++ b/modules/data/schematics-core/utility/parse-name.ts @@ -0,0 +1,16 @@ +import { Path, basename, dirname, normalize } from '@angular-devkit/core'; + +export interface Location { + name: string; + path: Path; +} + +export function parseName(path: string, name: string): Location { + const nameWithoutPath = basename(name as Path); + const namePath = dirname((path + '/' + name) as Path); + + return { + name: nameWithoutPath, + path: normalize('/' + namePath), + }; +} diff --git a/modules/data/schematics-core/utility/project.ts b/modules/data/schematics-core/utility/project.ts new file mode 100644 index 0000000000..43145d20fd --- /dev/null +++ b/modules/data/schematics-core/utility/project.ts @@ -0,0 +1,52 @@ +import { getWorkspace } from './config'; +import { Tree } from '@angular-devkit/schematics'; + +export interface WorkspaceProject { + root: string; + projectType: string; +} + +export function getProject( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +): WorkspaceProject { + const workspace = getWorkspace(host); + + if (!options.project) { + options.project = + workspace.defaultProject !== undefined + ? workspace.defaultProject + : Object.keys(workspace.projects)[0]; + } + + return workspace.projects[options.project]; +} + +export function getProjectPath( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +) { + const project = getProject(host, options); + + if (project.root.substr(-1) === '/') { + project.root = project.root.substr(0, project.root.length - 1); + } + + if (options.path === undefined) { + const projectDirName = + project.projectType === 'application' ? 'app' : 'lib'; + + return `${project.root ? `/${project.root}` : ''}/src/${projectDirName}`; + } + + return options.path; +} + +export function isLib( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +) { + const project = getProject(host, options); + + return project.projectType === 'library'; +} diff --git a/modules/data/schematics-core/utility/strings.ts b/modules/data/schematics-core/utility/strings.ts new file mode 100644 index 0000000000..5ebec70706 --- /dev/null +++ b/modules/data/schematics-core/utility/strings.ts @@ -0,0 +1,147 @@ +/** + * @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 + */ +const STRING_DASHERIZE_REGEXP = /[ _]/g; +const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; +const STRING_CAMELIZE_REGEXP = /(-|_|\.|\s)+(.)?/g; +const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; +const STRING_UNDERSCORE_REGEXP_2 = /-|\s+/g; + +/** + * Converts a camelized string into all lower case separated by underscores. + * + ```javascript + decamelize('innerHTML'); // 'inner_html' + decamelize('action_name'); // 'action_name' + decamelize('css-class-name'); // 'css-class-name' + decamelize('my favorite items'); // 'my favorite items' + ``` + */ +export function decamelize(str: string): string { + return str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase(); +} + +/** + Replaces underscores, spaces, or camelCase with dashes. + + ```javascript + dasherize('innerHTML'); // 'inner-html' + dasherize('action_name'); // 'action-name' + dasherize('css-class-name'); // 'css-class-name' + dasherize('my favorite items'); // 'my-favorite-items' + ``` + */ +export function dasherize(str?: string): string { + return decamelize(str || '').replace(STRING_DASHERIZE_REGEXP, '-'); +} + +/** + Returns the lowerCamelCase form of a string. + + ```javascript + camelize('innerHTML'); // 'innerHTML' + camelize('action_name'); // 'actionName' + camelize('css-class-name'); // 'cssClassName' + camelize('my favorite items'); // 'myFavoriteItems' + camelize('My Favorite Items'); // 'myFavoriteItems' + ``` + */ +export function camelize(str: string): string { + return str + .replace( + STRING_CAMELIZE_REGEXP, + (_match: string, _separator: string, chr: string) => { + return chr ? chr.toUpperCase() : ''; + } + ) + .replace(/^([A-Z])/, (match: string) => match.toLowerCase()); +} + +/** + Returns the UpperCamelCase form of a string. + + ```javascript + 'innerHTML'.classify(); // 'InnerHTML' + 'action_name'.classify(); // 'ActionName' + 'css-class-name'.classify(); // 'CssClassName' + 'my favorite items'.classify(); // 'MyFavoriteItems' + ``` + */ +export function classify(str: string): string { + return str + .split('.') + .map(part => capitalize(camelize(part))) + .join('.'); +} + +/** + More general than decamelize. Returns the lower\_case\_and\_underscored + form of a string. + + ```javascript + 'innerHTML'.underscore(); // 'inner_html' + 'action_name'.underscore(); // 'action_name' + 'css-class-name'.underscore(); // 'css_class_name' + 'my favorite items'.underscore(); // 'my_favorite_items' + ``` + */ +export function underscore(str: string): string { + return str + .replace(STRING_UNDERSCORE_REGEXP_1, '$1_$2') + .replace(STRING_UNDERSCORE_REGEXP_2, '_') + .toLowerCase(); +} + +/** + Returns the Capitalized form of a string + + ```javascript + 'innerHTML'.capitalize() // 'InnerHTML' + 'action_name'.capitalize() // 'Action_name' + 'css-class-name'.capitalize() // 'Css-class-name' + 'my favorite items'.capitalize() // 'My favorite items' + ``` + */ +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.substr(1); +} + +/** + Returns the plural form of a string + + ```javascript + 'innerHTML'.pluralize() // 'InnerHTMLs' + 'action_name'.pluralize() // 'actionNames' + 'css-class-name'.pluralize() // 'cssClassNames' + 'regex'.pluralize() // 'regexes' + 'user'.pluralize() // 'users' + ``` + */ +export function pluralize(str: string): string { + return camelize( + [/([^aeiou])y$/, /()fe?$/, /([^aeiou]o|[sxz]|[cs]h)$/].map( + (c, i) => (str = str.replace(c, `$1${'iv'[i] || ''}e`)) + ) && str + 's' + ); +} + +export function group(name: string, group: string | undefined) { + return group ? `${group}/${name}` : name; +} + +export function featurePath( + group: boolean | undefined, + flat: boolean | undefined, + path: string, + name: string +) { + if (group && !flat) { + return `../../${path}/${name}/`; + } + + return group ? `../${path}/` : './'; +} diff --git a/modules/data/schematics-core/utility/update.ts b/modules/data/schematics-core/utility/update.ts new file mode 100644 index 0000000000..4123bc42e8 --- /dev/null +++ b/modules/data/schematics-core/utility/update.ts @@ -0,0 +1,43 @@ +import { + Rule, + SchematicContext, + Tree, + SchematicsException, +} from '@angular-devkit/schematics'; + +export function updatePackage(name: string): Rule { + return (tree: Tree, context: SchematicContext) => { + 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 => { + const packageName = `@ngrx/${name}`; + + if (pkg[category] && pkg[category][packageName]) { + const firstChar = pkg[category][packageName][0]; + const suffix = match(firstChar, '^') || match(firstChar, '~'); + + pkg[category][packageName] = `${suffix}6.0.0`; + } + }); + + tree.overwrite(pkgPath, JSON.stringify(pkg, null, 2)); + + return tree; + }; +} + +function match(value: string, test: string) { + return value === test ? test : ''; +} diff --git a/modules/data/schematics/BUILD b/modules/data/schematics/BUILD new file mode 100644 index 0000000000..9fe9632e28 --- /dev/null +++ b/modules/data/schematics/BUILD @@ -0,0 +1,35 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "npm_package", "ts_library") + +ts_library( + name = "schematics", + srcs = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/*.spec.ts", + "**/files/**/*", + ], + ), + module_name = "@ngrx/data/schematics", + deps = [ + "//modules/data/schematics-core", + "@npm//@angular-devkit/schematics", + "@npm//typescript", + ], +) + +npm_package( + name = "npm_package", + srcs = [ + ":collection.json", + ] + glob([ + "**/files/**/*", + "**/schema.json", + ]), + deps = [ + ":schematics", + ], +) diff --git a/modules/data/schematics/collection.json b/modules/data/schematics/collection.json new file mode 100644 index 0000000000..43ad702bca --- /dev/null +++ b/modules/data/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "schematics": { + "ng-add": { + "aliases": ["init"], + "factory": "./ng-add", + "schema": "./ng-add/schema.json", + "description": "Add @ngrx/data to your application" + } + } +} diff --git a/modules/data/schematics/ng-add/index.spec.ts b/modules/data/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..b7f1cc9c33 --- /dev/null +++ b/modules/data/schematics/ng-add/index.spec.ts @@ -0,0 +1,321 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { Schema as DataEntityOptions } from './schema'; +import { + createWorkspace, + getTestProjectPath, +} from '../../../schematics-core/testing'; + +describe('Data ng-add Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/data', + path.join(__dirname, '../collection.json') + ); + const defaultOptions: DataEntityOptions = { + skipPackageJson: false, + project: 'bar', + module: 'app', + }; + + const projectPath = getTestProjectPath(); + + let appTree: UnitTestTree; + + beforeEach(() => { + appTree = createWorkspace(schematicRunner, appTree); + }); + + it('should update package.json', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['@ngrx/data']).toBeDefined(); + }); + + it('should skip package.json update', () => { + const options = { ...defaultOptions, skipPackageJson: true }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['@ngrx/data']).toBeUndefined(); + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).toMatch(/import { EntityDataModule } from '@ngrx\/data'/); + }); + + it('should fail if specified module does not exist', () => { + const options = { + ...defaultOptions, + module: `${projectPath}/src/app/app.moduleXXX.ts`, + }; + let thrownError: Error | null = null; + try { + schematicRunner.runSchematic('data', options, appTree); + } catch (err) { + thrownError = err; + } + expect(thrownError).toBeDefined(); + }); + + it('should import EntityDataModuleWithoutEffects into a specified module', () => { + const options = { + ...defaultOptions, + module: 'app.module.ts', + effects: false, + }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).toMatch( + /import { EntityDataModuleWithoutEffects } from '@ngrx\/data'/ + ); + }); + + it('should register EntityDataModule in the provided module', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).toMatch(/EntityDataModule\n/); + }); + + it('should register EntityDataModuleWithoutEffects in the provided module', () => { + const options = { ...defaultOptions, effects: false }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).toMatch(/EntityDataModuleWithoutEffects\n/); + }); + + describe('Migration of angular-ngrx-data', () => { + it('should remove angular-ngrx-data from package.json', () => { + const options = { ...defaultOptions, migrateNgrxData: true }; + + const packageJsonBefore = JSON.parse( + appTree.readContent('/package.json') + ); + packageJsonBefore['dependencies']['angular-ngrx-data'] = '1.0.0'; + appTree.overwrite( + '/package.json', + JSON.stringify(packageJsonBefore, null, 2) + ); + + expect( + JSON.parse(appTree.readContent('/package.json'))['dependencies'][ + 'angular-ngrx-data' + ] + ).toBeDefined(); + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['angular-ngrx-data']).not.toBeDefined(); + }); + + it('should rename NgrxDataModule', () => { + const options = { + ...defaultOptions, + migrateNgrxData: true, + }; + + const dataModulePath = '/data.module.ts'; + + const input = ` + import { + DefaultDataServiceConfig, + EntityDataService, + EntityHttpResourceUrls, + EntityServices, + Logger, + NgrxDataModule, + Pluralizer + } from 'ngrx-data'; + + @NgModule({ + imports: [ + NgrxDataModule.forRoot({ + entityMetadata: entityMetadata, + pluralNames: pluralNames + }) + ], + }) + export class AppModule {} + `; + + const output = ` + import { + DefaultDataServiceConfig, + EntityDataService, + EntityHttpResourceUrls, + EntityServices, + Logger, + EntityDataModule, + Pluralizer + } from '@ngrx/data'; + + @NgModule({ + imports: [ + EntityDataModule.forRoot({ + entityMetadata: entityMetadata, + pluralNames: pluralNames + }) + ], + }) + export class AppModule {} + `; + appTree.create(dataModulePath, input); + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const actual = tree.readContent(dataModulePath); + + expect(actual).toBe(output); + }); + + it('should rename NgrxDataModuleWithoutEffects ', () => { + const options = { + ...defaultOptions, + migrateNgrxData: true, + }; + + const dataModulePath = '/data.module.ts'; + + const input = ` + import { + DefaultDataServiceConfig, + EntityDataService, + EntityHttpResourceUrls, + EntityServices, + Logger, + NgrxDataModuleWithoutEffects, + Pluralizer + } from 'ngrx-data'; + + @NgModule({ + imports: [ + NgrxDataModuleWithoutEffects.forRoot({ + entityMetadata: entityMetadata, + pluralNames: pluralNames + }) + ], + }) + export class AppModule {} + `; + + const output = ` + import { + DefaultDataServiceConfig, + EntityDataService, + EntityHttpResourceUrls, + EntityServices, + Logger, + EntityDataModuleWithoutEffects, + Pluralizer + } from '@ngrx/data'; + + @NgModule({ + imports: [ + EntityDataModuleWithoutEffects.forRoot({ + entityMetadata: entityMetadata, + pluralNames: pluralNames + }) + ], + }) + export class AppModule {} + `; + appTree.create(dataModulePath, input); + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const actual = tree.readContent(dataModulePath); + + expect(actual).toBe(output); + }); + + it('should rename NgrxDataModuleConfig ', () => { + const options = { + ...defaultOptions, + migrateNgrxData: true, + }; + + const dataModulePath = '/data.module.ts'; + + const input = ` + import { + DefaultDataServiceConfig, + EntityDataService, + EntityHttpResourceUrls, + EntityServices, + Logger, + NgrxDataModule, + NgrxDataModuleConfig, + Pluralizer + } from 'ngrx-data'; + + const customConfig: NgrxDataModuleConfig = { + root: 'api', // default root path to the server's web api + timeout: 3000, // request timeout + }; + + @NgModule({ + imports: [ + NgrxDataModule.forRoot({ + entityMetadata: entityMetadata, + pluralNames: pluralNames + }) + ], + providers: [ + { provide: DefaultDataServiceConfig, useValue: customConfig }, + ] + }) + export class AppModule {} + `; + + const output = ` + import { + DefaultDataServiceConfig, + EntityDataService, + EntityHttpResourceUrls, + EntityServices, + Logger, + EntityDataModule, + EntityDataModuleConfig, + Pluralizer + } from '@ngrx/data'; + + const customConfig: EntityDataModuleConfig = { + root: 'api', // default root path to the server's web api + timeout: 3000, // request timeout + }; + + @NgModule({ + imports: [ + EntityDataModule.forRoot({ + entityMetadata: entityMetadata, + pluralNames: pluralNames + }) + ], + providers: [ + { provide: DefaultDataServiceConfig, useValue: customConfig }, + ] + }) + export class AppModule {} + `; + appTree.create(dataModulePath, input); + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const actual = tree.readContent(dataModulePath); + + expect(actual).toBe(output); + }); + }); +}); diff --git a/modules/data/schematics/ng-add/index.ts b/modules/data/schematics/ng-add/index.ts new file mode 100644 index 0000000000..0736b2fb83 --- /dev/null +++ b/modules/data/schematics/ng-add/index.ts @@ -0,0 +1,304 @@ +import * as ts from 'typescript'; +import { + Rule, + SchematicContext, + Tree, + chain, + noop, + SchematicsException, +} from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import { + addPackageToPackageJson, + platformVersion, + findModuleFromOptions, + insertImport, + InsertChange, + getProjectPath, + parseName, + addImportToModule, + createReplaceChange, + ReplaceChange, + createChangeRecorder, +} from '@ngrx/data/schematics-core'; +import { Schema as EntityDataOptions } from './schema'; +import { Path } from '@angular-devkit/core'; + +function addNgRxDataToPackageJson() { + return (host: Tree, context: SchematicContext) => { + addPackageToPackageJson( + host, + 'dependencies', + '@ngrx/data', + platformVersion + ); + context.addTask(new NodePackageInstallTask()); + return host; + }; +} + +function addEntityDataToNgModule(options: EntityDataOptions): Rule { + return (host: Tree) => { + throwIfModuleNotSpecified(host, options.module); + + const modulePath = options.module!; + const text = host.read(modulePath)!.toString(); + + const source = ts.createSourceFile( + modulePath, + text, + ts.ScriptTarget.Latest, + true + ); + + const moduleToImport = options.effects + ? 'EntityDataModule' + : 'EntityDataModuleWithoutEffects'; + const effectsModuleImport = insertImport( + source, + modulePath, + moduleToImport, + '@ngrx/data' + ); + + const [dateEntityNgModuleImport] = addImportToModule( + source, + modulePath, + moduleToImport, + '' + ); + + const changes = [effectsModuleImport, dateEntityNgModuleImport]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +const renames = { + NgrxDataModule: 'EntityDataModule', + NgrxDataModuleWithoutEffects: 'EntityDataModuleWithoutEffects', + NgrxDataModuleConfig: 'EntityDataModuleConfig', +}; + +function removeAngularNgRxDataFromPackageJson() { + return (host: Tree) => { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + + if (json['dependencies'] && json['dependencies']['angular-ngrx-data']) { + delete json['dependencies']['angular-ngrx-data']; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; + }; +} + +function renameNgrxDataModule(options: EntityDataOptions) { + return (host: Tree, context: SchematicContext) => { + host.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const sourceFile = ts.createSourceFile( + path, + host.read(path)!.toString(), + ts.ScriptTarget.Latest + ); + + if (sourceFile.isDeclarationFile) { + return; + } + + const ngrxDataImports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === "'ngrx-data'" + ); + + if (ngrxDataImports.length === 0) { + return; + } + + const changes = [ + ...findNgrxDataImports(sourceFile, path, ngrxDataImports), + ...findNgrxDataImportDeclarations(sourceFile, path, ngrxDataImports), + ...findNgrxDataReplacements(sourceFile, path), + ]; + + if (changes.length === 0) { + return; + } + + const recorder = createChangeRecorder(host, path, changes); + host.commitUpdate(recorder); + }); + }; +} + +function findNgrxDataImports( + sourceFile: ts.SourceFile, + path: Path, + imports: ts.ImportDeclaration[] +) { + const changes = imports.map(specifier => + createReplaceChange( + sourceFile, + path, + specifier.moduleSpecifier, + "'ngrx-data'", + "'@ngrx/data'" + ) + ); + + return changes; +} + +function findNgrxDataImportDeclarations( + sourceFile: ts.SourceFile, + path: Path, + imports: ts.ImportDeclaration[] +) { + 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 }; + } + + const ngrxDataImports = Object.keys(renames); + if (ngrxDataImports.includes(specifier.name.text)) { + return { hit: true, specifier, text: specifier.name.text }; + } + + // if import is renamed + if ( + specifier.propertyName && + ngrxDataImports.includes(specifier.propertyName.text) + ) { + return { hit: true, specifier, text: specifier.propertyName.text }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit) + .map(({ specifier, text }) => + createReplaceChange( + sourceFile, + path, + specifier!, + text!, + (renames as any)[text!] + ) + ); + + return changes; +} + +function findNgrxDataReplacements(sourceFile: ts.SourceFile, path: Path) { + const renameKeys = Object.keys(renames); + let changes: ReplaceChange[] = []; + ts.forEachChild(sourceFile, node => find(node, changes)); + return changes; + + function find(node: ts.Node, changes: ReplaceChange[]) { + let change = undefined; + + if ( + ts.isPropertyAssignment(node) && + renameKeys.includes(node.initializer.getText(sourceFile)) + ) { + change = { + node: node.initializer, + text: node.initializer.getText(sourceFile), + }; + } + + if ( + ts.isPropertyAccessExpression(node) && + renameKeys.includes(node.expression.getText(sourceFile)) + ) { + change = { + node: node.expression, + text: node.expression.getText(sourceFile), + }; + } + + if ( + ts.isVariableDeclaration(node) && + node.type && + renameKeys.includes(node.type.getText(sourceFile)) + ) { + change = { + node: node.type, + text: node.type.getText(sourceFile), + }; + } + + if (change) { + changes.push( + createReplaceChange( + sourceFile, + path, + change.node, + change.text, + (renames as any)[change.text] + ) + ); + } + + ts.forEachChild(node, childNode => find(childNode, changes)); + } +} + +function throwIfModuleNotSpecified(host: Tree, module?: string) { + if (!module) { + throw new Error('Module not specified'); + } + + if (!host.exists(module)) { + throw new Error('Specified module does not exist'); + } + + const text = host.read(module); + if (text === null) { + throw new SchematicsException(`File ${module} does not exist.`); + } +} + +export default function(options: EntityDataOptions): Rule { + return (host: Tree, context: SchematicContext) => { + (options as any).name = ''; + options.path = getProjectPath(host, options); + options.effects = options.effects === undefined ? true : options.effects; + options.module = options.module + ? findModuleFromOptions(host, options as any) + : options.module; + + const parsedPath = parseName(options.path, ''); + options.path = parsedPath.path; + + return chain([ + options && options.skipPackageJson ? noop() : addNgRxDataToPackageJson(), + options.migrateNgrxData + ? chain([ + removeAngularNgRxDataFromPackageJson(), + renameNgrxDataModule(options), + ]) + : addEntityDataToNgModule(options), + ])(host, context); + }; +} diff --git a/modules/data/schematics/ng-add/schema.json b/modules/data/schematics/ng-add/schema.json new file mode 100644 index 0000000000..4e9c3ed2d9 --- /dev/null +++ b/modules/data/schematics/ng-add/schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxData", + "title": "NgRx Data Schema", + "type": "object", + "properties": { + "effect": { + "type": "boolean", + "default": true, + "description": + "When false use the EntityDataModuleWithoutEffects module instead of EntityDataModule." + }, + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": + "Do not add @ngrx/data as dependency to package.json (e.g., --skipPackageJson)." + }, + "path": { + "type": "string", + "format": "path", + "description": "The path to the module.", + "visible": false + }, + "project": { + "type": "string", + "description": "The name of the project.", + "aliases": ["p"] + }, + "module": { + "type": "string", + "default": "app", + "description": "Allows specification of the declaring module.", + "alias": "m", + "subtype": "filepath" + }, + "migrateNgrxData": { + "type": "boolean", + "default": false, + "description": "Migrate from angular-ngrx-data, will rename modules.", + "alias": "migrate" + } + }, + "required": [] +} diff --git a/modules/data/schematics/ng-add/schema.ts b/modules/data/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..1885f1247a --- /dev/null +++ b/modules/data/schematics/ng-add/schema.ts @@ -0,0 +1,8 @@ +export interface Schema { + path?: string; + effects?: boolean; + skipPackageJson?: boolean; + project?: string; + module?: string; + migrateNgrxData?: boolean; +} diff --git a/projects/ngrx.io/content/guide/migration/v8.md b/projects/ngrx.io/content/guide/migration/v8.md index b7137e141f..0f09ed8b0f 100644 --- a/projects/ngrx.io/content/guide/migration/v8.md +++ b/projects/ngrx.io/content/guide/migration/v8.md @@ -133,3 +133,19 @@ StoreDevtoolsModule.instrument({ actionsBlocklist: ['...'] }) ``` + +## @ngrx/data + +### Renames + +To stay consistent with the other `@ngrx/*` packages, the following has been renamed: + +- `NgrxDataModule` is renamed to `EntityDataModule` +- `NgrxDataModuleWithoutEffects` is renamed to `EntityDataModuleWithoutEffects` +- `NgrxDataModuleConfig` is renamed to `EntityDataModuleConfig` + +
+ +The installation of `@ngrx/data` package via `ng add @ngrx/data` will remove `angular-ngrx-data` from the `package.json` and will also perform these renames. + +
\ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 385dae44bc..afe04f4b0b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,8 @@ "rootDir": "./", "strict": true, "paths": { + "@ngrx/data": ["./modules/data"], + "@ngrx/data/schematics-core": ["./modules/data/schematics-core"], "@ngrx/effects": ["./modules/effects"], "@ngrx/effects/testing": ["./modules/effects/testing"], "@ngrx/effects/schematics-core": ["./modules/effects/schematics-core"],