diff --git a/packages/angular_devkit/schematics/collection-schema.json b/packages/angular_devkit/schematics/collection-schema.json index f6a8b18aeb..ac9642962f 100644 --- a/packages/angular_devkit/schematics/collection-schema.json +++ b/packages/angular_devkit/schematics/collection-schema.json @@ -4,6 +4,22 @@ "title": "Collection Schema for validating a 'collection.json'.", "type": "object", "properties": { + "extends": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + ] + }, "schematics": { "type": "object", "description": "A map of schematic names to schematic details", diff --git a/packages/angular_devkit/schematics/src/engine/collection.ts b/packages/angular_devkit/schematics/src/engine/collection.ts index 362d78b492..f7d36f1113 100644 --- a/packages/angular_devkit/schematics/src/engine/collection.ts +++ b/packages/angular_devkit/schematics/src/engine/collection.ts @@ -12,7 +12,8 @@ import { Collection, CollectionDescription, Schematic } from './interface'; export class CollectionImpl implements Collection { constructor(private _description: CollectionDescription, - private _engine: SchematicEngine) { + private _engine: SchematicEngine, + public readonly baseDescriptions?: Array>) { } get description() { return this._description; } diff --git a/packages/angular_devkit/schematics/src/engine/engine.ts b/packages/angular_devkit/schematics/src/engine/engine.ts index ce735708f5..a877e2f9fb 100644 --- a/packages/angular_devkit/schematics/src/engine/engine.ts +++ b/packages/angular_devkit/schematics/src/engine/engine.ts @@ -40,6 +40,13 @@ export class UnknownUrlSourceProtocol extends BaseException { export class UnknownCollectionException extends BaseException { constructor(name: string) { super(`Unknown collection "${name}".`); } } + +export class CircularCollectionException extends BaseException { + constructor(name: string) { + super(`Circular collection reference "${name}".`); + } +} + export class UnknownSchematicException extends BaseException { constructor(name: string, collection: CollectionDescription<{}>) { super(`Schematic "${name}" not found in collection "${collection.name}".`); @@ -76,16 +83,38 @@ export class SchematicEngine(description, this, bases); + this._collectionCache.set(name, collection); + this._schematicCache.set(name, new Map()); + + return collection; + } + + private _createCollectionDescription( + name: string, + parentNames?: Set, + ): [CollectionDescription, Array>] { const description = this._host.createCollectionDescription(name); if (!description) { throw new UnknownCollectionException(name); } + if (parentNames && parentNames.has(description.name)) { + throw new CircularCollectionException(name); + } - collection = new CollectionImpl(description, this); - this._collectionCache.set(name, collection); - this._schematicCache.set(name, new Map()); + const bases = new Array>(); + if (description.extends) { + parentNames = (parentNames || new Set()).add(description.name); + for (const baseName of description.extends) { + const [base, baseBases] = this._createCollectionDescription(baseName, new Set(parentNames)); - return collection; + bases.unshift(base, ...baseBases); + } + } + + return [description, bases]; } createContext( @@ -148,12 +177,25 @@ export class SchematicEngine(description, factory, collection, this); schematicMap.set(name, schematic); @@ -161,8 +203,17 @@ export class SchematicEngine) { - return this._host.listSchematicNames(collection.description); + listSchematicNames(collection: Collection): string[] { + const names = this._host.listSchematicNames(collection.description); + + if (collection.baseDescriptions) { + for (const base of collection.baseDescriptions) { + names.push(...this._host.listSchematicNames(base)); + } + } + + // remove duplicates + return [...new Set(names)]; } transformOptions( diff --git a/packages/angular_devkit/schematics/src/engine/interface.ts b/packages/angular_devkit/schematics/src/engine/interface.ts index 5350833e51..746eccd98d 100644 --- a/packages/angular_devkit/schematics/src/engine/interface.ts +++ b/packages/angular_devkit/schematics/src/engine/interface.ts @@ -19,6 +19,7 @@ import { TaskConfigurationGenerator, TaskExecutor, TaskId } from './task'; */ export type CollectionDescription = CollectionMetadataT & { readonly name: string; + readonly extends?: string[]; }; /** @@ -49,7 +50,7 @@ export interface EngineHost): - SchematicDescription; + SchematicDescription | null; getSchematicRuleFactory( schematic: SchematicDescription, collection: CollectionDescription): RuleFactory; @@ -108,6 +109,7 @@ export interface Engine { readonly description: CollectionDescription; + readonly baseDescriptions?: Array>; createSchematic(name: string): Schematic; listSchematicNames(): string[]; diff --git a/packages/angular_devkit/schematics/tools/fallback-engine-host.ts b/packages/angular_devkit/schematics/tools/fallback-engine-host.ts index 51870486b9..89f3dd891a 100644 --- a/packages/angular_devkit/schematics/tools/fallback-engine-host.ts +++ b/packages/angular_devkit/schematics/tools/fallback-engine-host.ts @@ -68,8 +68,11 @@ export class FallbackEngineHost implements EngineHost<{}, {}> { createSchematicDescription( name: string, collection: CollectionDescription, - ): SchematicDescription { + ): SchematicDescription | null { const description = collection.host.createSchematicDescription(name, collection.description); + if (!description) { + return null; + } return { name, collection, description }; } diff --git a/packages/angular_devkit/schematics/tools/file-system-engine-host-base.ts b/packages/angular_devkit/schematics/tools/file-system-engine-host-base.ts index a83722aeca..6dfdda90fd 100644 --- a/packages/angular_devkit/schematics/tools/file-system-engine-host-base.ts +++ b/packages/angular_devkit/schematics/tools/file-system-engine-host-base.ts @@ -20,7 +20,6 @@ import { Source, TaskExecutor, TaskExecutorFactory, - UnknownSchematicException, UnregisteredTaskException, } from '../src'; import { @@ -142,6 +141,11 @@ export abstract class FileSystemEngineHostBase implements throw new InvalidCollectionJsonException(name, path); } + // normalize extends property to an array + if (typeof jsonValue['extends'] === 'string') { + jsonValue['extends'] = [jsonValue['extends']]; + } + const description = this._transformCollectionDescription(name, { ...jsonValue, path, @@ -169,7 +173,7 @@ export abstract class FileSystemEngineHostBase implements createSchematicDescription( name: string, collection: FileSystemCollectionDesc, - ): FileSystemSchematicDesc { + ): FileSystemSchematicDesc | null { // Resolve aliases first. for (const schematicName of Object.keys(collection.schematics)) { const schematicDescription = collection.schematics[schematicName]; @@ -180,13 +184,13 @@ export abstract class FileSystemEngineHostBase implements } if (!(name in collection.schematics)) { - throw new UnknownSchematicException(name, collection); + return null; } const collectionPath = dirname(collection.path); const partialDesc: Partial | null = collection.schematics[name]; if (!partialDesc) { - throw new UnknownSchematicException(name, collection); + return null; } if (partialDesc.extends) { diff --git a/packages/angular_devkit/schematics/tools/file-system-engine-host_spec.ts b/packages/angular_devkit/schematics/tools/file-system-engine-host_spec.ts index c02c004e50..33dd6877f1 100644 --- a/packages/angular_devkit/schematics/tools/file-system-engine-host_spec.ts +++ b/packages/angular_devkit/schematics/tools/file-system-engine-host_spec.ts @@ -39,6 +39,163 @@ describe('FileSystemEngineHost', () => { expect(schematic1.description.name).toBe('schematic1'); }); + it('lists schematics but not aliases', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + const testCollection = engine.createCollection('aliases'); + const names = testCollection.listSchematicNames(); + + expect(names).not.toBeNull(); + expect(names[0]).toBe('schematic1'); + expect(names[1]).toBe('schematic2'); + }); + + it('extends a collection with string', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + const testCollection = engine.createCollection('extends-basic-string'); + + expect(testCollection.baseDescriptions).not.toBeUndefined(); + expect(testCollection.baseDescriptions + && testCollection.baseDescriptions.length).toBe(1); + + const schematic1 = engine.createSchematic('schematic1', testCollection); + + expect(schematic1).not.toBeNull(); + expect(schematic1.description.name).toBe('schematic1'); + + const schematic2 = engine.createSchematic('schematic2', testCollection); + + expect(schematic2).not.toBeNull(); + expect(schematic2.description.name).toBe('schematic2'); + + const names = testCollection.listSchematicNames(); + + expect(names.length).toBe(2); + }); + + it('extends a collection with array', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + const testCollection = engine.createCollection('extends-basic'); + + expect(testCollection.baseDescriptions).not.toBeUndefined(); + expect(testCollection.baseDescriptions + && testCollection.baseDescriptions.length).toBe(1); + + const schematic1 = engine.createSchematic('schematic1', testCollection); + + expect(schematic1).not.toBeNull(); + expect(schematic1.description.name).toBe('schematic1'); + + const schematic2 = engine.createSchematic('schematic2', testCollection); + + expect(schematic2).not.toBeNull(); + expect(schematic2.description.name).toBe('schematic2'); + + const names = testCollection.listSchematicNames(); + + expect(names.length).toBe(2); + }); + + it('extends a collection with full depth', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + const testCollection = engine.createCollection('extends-deep'); + + expect(testCollection.baseDescriptions).not.toBeUndefined(); + expect(testCollection.baseDescriptions + && testCollection.baseDescriptions.length).toBe(2); + + const schematic1 = engine.createSchematic('schematic1', testCollection); + + expect(schematic1).not.toBeNull(); + expect(schematic1.description.name).toBe('schematic1'); + + const schematic2 = engine.createSchematic('schematic2', testCollection); + + expect(schematic2).not.toBeNull(); + expect(schematic2.description.name).toBe('schematic2'); + + const names = testCollection.listSchematicNames(); + + expect(names.length).toBe(2); + }); + + it('replaces base schematics when extending', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + const testCollection = engine.createCollection('extends-replace'); + + expect(testCollection.baseDescriptions).not.toBeUndefined(); + expect(testCollection.baseDescriptions + && testCollection.baseDescriptions.length).toBe(1); + + const schematic1 = engine.createSchematic('schematic1', testCollection); + + expect(schematic1).not.toBeNull(); + expect(schematic1.description.name).toBe('schematic1'); + expect(schematic1.description.description).toBe('replaced'); + + const names = testCollection.listSchematicNames(); + + expect(names).not.toBeNull(); + expect(names.length).toBe(1); + }); + + it('extends multiple collections', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + const testCollection = engine.createCollection('extends-multiple'); + + expect(testCollection.baseDescriptions).not.toBeUndefined(); + expect(testCollection.baseDescriptions + && testCollection.baseDescriptions.length).toBe(4); + + const schematic1 = engine.createSchematic('schematic1', testCollection); + + expect(schematic1).not.toBeNull(); + expect(schematic1.description.name).toBe('schematic1'); + expect(schematic1.description.description).toBe('replaced'); + + const schematic2 = engine.createSchematic('schematic2', testCollection); + + expect(schematic2).not.toBeNull(); + expect(schematic2.description.name).toBe('schematic2'); + + const names = testCollection.listSchematicNames(); + + expect(names).not.toBeNull(); + expect(names.length).toBe(2); + }); + + it('errors on simple circular collections', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + expect(() => engine.createCollection('extends-circular')).toThrow(); + }); + + it('errors on complex circular collections', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + expect(() => engine.createCollection('extends-circular-multiple')).toThrow(); + }); + + it('errors on deep circular collections', () => { + const engineHost = new FileSystemEngineHost(root); + const engine = new SchematicEngine(engineHost); + + expect(() => engine.createCollection('extends-circular-deep')).toThrow(); + }); + it('errors on invalid aliases', () => { const engineHost = new FileSystemEngineHost(root); const engine = new SchematicEngine(engineHost); diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-basic-string/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-basic-string/collection.json new file mode 100644 index 0000000000..f21d316bf7 --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-basic-string/collection.json @@ -0,0 +1,10 @@ +{ + "name": "extends-basic-string", + "extends": "works", + "schematics": { + "schematic2": { + "description": "2", + "factory": "../null-factory" + } + } +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-basic/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-basic/collection.json new file mode 100644 index 0000000000..e20abcc174 --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-basic/collection.json @@ -0,0 +1,12 @@ +{ + "name": "extends-basic", + "extends": [ + "works" + ], + "schematics": { + "schematic2": { + "description": "2", + "factory": "../null-factory" + } + } +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-deep/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-deep/collection.json new file mode 100644 index 0000000000..e717cc7fb2 --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-deep/collection.json @@ -0,0 +1,10 @@ +{ + "name": "extends-circular-deep", + "extends": "extends-circular-multiple", + "schematics": { + "schematic2": { + "description": "2", + "factory": "../null-factory" + } + } +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-middle/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-middle/collection.json new file mode 100644 index 0000000000..703a0514cc --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-middle/collection.json @@ -0,0 +1,13 @@ +{ + "name": "extends-circular-middle", + "extends": [ + "extends-multiple", + "extends-circular-multiple" + ], + "schematics": { + "schematic2": { + "description": "2", + "factory": "../null-factory" + } + } +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-multiple/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-multiple/collection.json new file mode 100644 index 0000000000..2581e5d6b8 --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular-multiple/collection.json @@ -0,0 +1,13 @@ +{ + "name": "extends-circular-multiple", + "extends": [ + "extends-multiple", + "extends-circular-middle" + ], + "schematics": { + "schematic2": { + "description": "2", + "factory": "../null-factory" + } + } +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular/collection.json new file mode 100644 index 0000000000..c588bedf62 --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-circular/collection.json @@ -0,0 +1,10 @@ +{ + "name": "extends-circular", + "extends": "extends-circular", + "schematics": { + "schematic2": { + "description": "2", + "factory": "../null-factory" + } + } +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-deep/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-deep/collection.json new file mode 100644 index 0000000000..56decb72ab --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-deep/collection.json @@ -0,0 +1,5 @@ +{ + "name": "extends-deep", + "extends": "extends-basic", + "schematics": {} +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-multiple/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-multiple/collection.json new file mode 100644 index 0000000000..59c58c152d --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-multiple/collection.json @@ -0,0 +1,8 @@ +{ + "name": "extends-multiple", + "extends": [ + "extends-basic", + "extends-replace" + ], + "schematics": {} +} diff --git a/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-replace/collection.json b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-replace/collection.json new file mode 100644 index 0000000000..7fdce6f449 --- /dev/null +++ b/tests/@angular_devkit/schematics/tools/file-system-engine-host/extends-replace/collection.json @@ -0,0 +1,12 @@ +{ + "name": "extends-replace", + "extends": [ + "works" + ], + "schematics": { + "schematic1": { + "description": "replaced", + "factory": "../null-factory" + } + } +}