diff --git a/README.md b/README.md index 0a33ceb..d7a606f 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,81 @@ new ProjenStruct(project, { name: 'MyProjectOptions'}) }); ``` +### Updating multiple properties + +A callback function can be passed to `updateEvery()` to update multiple properties at a time. + +Use `updateAll()` to uniformly update all properties. +A convenience `allOptional()` method is provided to make all properties optional. + +```js +new ProjenStruct(project, { name: 'MyProjectOptions'}) + .mixin(Struct.fromFqn('projen.typescript.TypeScriptProjectOptions')) + + // Use a callback to make conditional updates + .updateEvery((property) => { + if (!property.optional) { + return { + docs: { + remarks: 'This property is required.', + }, + }; + } + return {}; + }) + + // Apply an update to all properties + .updateAll({ + immutable: true, + }) + + // Mark all properties as optional + .allOptional(); +``` + +### Replacing properties + +Existing properties can be replaced with a new `@jsii/spec` definition. +If a different `name` is provided, the property is also renamed. + +A callback function can be passed to `map()` to map every property to a new `@jsii/spec` definition. + +```ts +new ProjenStruct(project, { name: 'MyProjectOptions' }) + .mixin(Struct.fromFqn('projen.typescript.TypeScriptProjectOptions')) + + // Replace a property with an entirely new definition + .replace('autoApproveOptions', { + name: 'autoApproveOptions', + type: { fqn: 'my_project.AutoApproveOptions' }, + docs: { + summary: 'Configure the auto-approval workflow.' + } + }) + + // Passing a new name, will also rename the property + .replace('autoMergeOptions', { + name: 'mergeFlowOptions', + type: { fqn: 'my_project.MergeFlowOptions' }, + }) + + // Use a callback to map every property to a new definition + .map((property) => { + if (property.protected) { + return { + ...property, + protected: false, + docs: { + custom: { + 'internal': 'true', + } + } + } + } + return property; + }); +``` + ### Filter properties Arbitrary predicate functions can be used to filter properties. diff --git a/src/builder/struct.ts b/src/builder/struct.ts index 8f85b4a..e387fd9 100644 --- a/src/builder/struct.ts +++ b/src/builder/struct.ts @@ -34,48 +34,50 @@ export interface HasStructSpec { export interface IStructBuilder { /** - * Keep only the properties that meet the condition specified in the callback function. + * Add properties. + * + * In the same call, the first defined properties take priority. + * However later calls will overwrite existing properties. */ - filter(predicate: (prop: Property) => boolean): IStructBuilder; + add(...props: Property[]): IStructBuilder; /** - * Only keep these properties. + * Mix the properties of these sources into the struct. + * + * In the same call, the first defined sources and properties take priority. + * However later calls will overwrite existing properties. */ - only(...keep: string[]): IStructBuilder; + mixin(...sources: HasProperties[]): IStructBuilder; /** - * Omit these properties. + * Replaces an existing property with a new spec. */ - omit(...remove: string[]): IStructBuilder; + replace(name: string, replacement: Property): IStructBuilder; /** - * Mark all properties as optional. + * Calls a defined callback function on each property, and replaces the property with the returned property. + * + * @param callbackfn — A function that accepts a property spec as an argument. The map method calls the callbackfn function one time for each property. */ - allOptional(): IStructBuilder; + map(callbackfn: (prop: Property) => Property): IStructBuilder; /** - * Remove all deprecated properties. + * Update an existing property. */ - withoutDeprecated(): IStructBuilder; + update(name: string, update: Partial): IStructBuilder; /** - * Add properties. + * Calls a defined callback function on each property, and merges the property with the returned property partial. * - * In the same call, the first defined properties take priority. - * However later calls will overwrite existing properties. + * @param callbackfn — A function that accepts a property spec as an argument. The map method calls the callbackfn function one time for each property. */ - add(...props: Property[]): IStructBuilder; + updateEvery(callbackfn: (prop: Property) => Partial): IStructBuilder; /** * Update all existing properties. */ updateAll(update: Partial): IStructBuilder; - /** - * Update an existing property. - */ - update(name: string, update: Partial): IStructBuilder; - /** * Rename a property. * @@ -84,12 +86,29 @@ export interface IStructBuilder { rename(from: string, to: string): IStructBuilder; /** - * Mix the properties of these sources into the struct. - * - * In the same call, the first defined sources and properties take priority. - * However later calls will overwrite existing properties. + * Mark all properties as optional. */ - mixin(...sources: HasProperties[]): IStructBuilder; + allOptional(): IStructBuilder; + + /** + * Keep only the properties that meet the condition specified in the callback function. + */ + filter(predicate: (prop: Property) => boolean): IStructBuilder; + + /** + * Only keep these properties. + */ + only(...keep: string[]): IStructBuilder; + + /** + * Omit these properties. + */ + omit(...remove: string[]): IStructBuilder; + + /** + * Remove all deprecated properties. + */ + withoutDeprecated(): IStructBuilder; } /** @@ -138,43 +157,41 @@ export class Struct implements IStructBuilder, HasProperties, HasFullyQualifiedN ); } - public filter(predicate: (prop: Property) => boolean): this { - for (const propertyKey of this._properties.keys()) { - if (!predicate(this._properties.get(propertyKey)!)) { - this._properties.delete(propertyKey); - } + public add(...props: Property[]): this { + for (const prop of props.reverse()) { + this._properties.set(prop.name, prop); } return this; } - public only(...keep: string[]): this { - return this.filter((prop) => keep.includes(prop.name)); - } - - public omit(...remove: string[]): this { - for (const prop of remove) { - this._properties.delete(prop); + public mixin(...sources: HasProperties[]): this { + for (const source of sources.reverse()) { + this.add(...(source.properties || [])); } return this; } - public withoutDeprecated(): this { - return this.filter((prop) => null == prop.docs?.deprecated); - } + public replace(name: string, replacement: Property): this { + const current = this._properties.get(name); - public allOptional(): this { - this._properties.forEach((property) => { - property.optional = true; - }); + if (!current) { + throw `Unable to replace property '${name}' in '${this._base.fqn}: Property does not exists, please use \`add\`.'`; + } - return this; + if (replacement.name !== name) { + this.omit(name); + } + + return this.add(replacement); } - public add(...props: Property[]): this { - for (const prop of props.reverse()) { - this._properties.set(prop.name, prop); + public map(callbackfn: (prop: Property) => Property): this { + const keys = this._properties.keys(); + for (const propertyKey of keys) { + const current = structuredClone(this._properties.get(propertyKey)!); + this.replace(propertyKey, callbackfn(current)); } return this; @@ -207,6 +224,16 @@ export class Struct implements IStructBuilder, HasProperties, HasFullyQualifiedN return this.add(updatedProp); } + public updateEvery(callbackfn: (prop: Property) => Partial): this { + const keys = this._properties.keys(); + for (const propertyKey of keys) { + const current = structuredClone(this._properties.get(propertyKey)!); + this.update(current.name, callbackfn(current) ?? {}); + } + + return this; + } + public updateAll(update: Partial): this { for (const propertyKey of this._properties.keys()) { this.update(propertyKey, update); @@ -218,14 +245,41 @@ export class Struct implements IStructBuilder, HasProperties, HasFullyQualifiedN return this.update(from, { name: to }); } - public mixin(...sources: HasProperties[]): this { - for (const source of sources.reverse()) { - this.add(...(source.properties || [])); + public allOptional(): this { + this.map((property) => { + property.optional = true; + return property; + }); + + return this; + } + + public filter(predicate: (prop: Property) => boolean): this { + for (const propertyKey of this._properties.keys()) { + if (!predicate(this._properties.get(propertyKey)!)) { + this._properties.delete(propertyKey); + } } return this; } + public only(...keep: string[]): this { + return this.filter((prop) => keep.includes(prop.name)); + } + + public omit(...remove: string[]): this { + for (const prop of remove) { + this._properties.delete(prop); + } + + return this; + } + + public withoutDeprecated(): this { + return this.filter((prop) => null == prop.docs?.deprecated); + } + /** * Get the current state of the builder */ diff --git a/src/projen/projen-struct.ts b/src/projen/projen-struct.ts index cfd8e86..3a85867 100644 --- a/src/projen/projen-struct.ts +++ b/src/projen/projen-struct.ts @@ -96,34 +96,30 @@ export class ProjenStruct extends Component implements IStructBuilder, HasProper public get spec() { return this.builder.spec; } - filter(predicate: (prop: Property) => boolean): this { - this.builder.filter(predicate); - return this; - } - only(...keep: string[]): this { - this.builder.only(...keep); - return this; - } - omit(...remove: string[]): this { - this.builder.omit(...remove); + add(...props: Property[]): this { + this.builder.add(...props); return this; } - withoutDeprecated(): this { - this.builder.withoutDeprecated(); + mixin(...sources: HasProperties[]): this { + this.builder.mixin(...sources); return this; } - allOptional(): this { - this.builder.allOptional(); + replace(name: string, replacement: Property): IStructBuilder { + this.builder.replace(name, replacement); return this; } - add(...props: Property[]): this { - this.builder.add(...props); + map(callbackfn: (prop: Property) => Property): this { + this.builder.map(callbackfn); return this; } update(name: string, update: Partial): this { this.builder.update(name, update); return this; } + updateEvery(callbackfn: (prop: Property) => Partial): this { + this.builder.updateEvery(callbackfn); + return this; + } updateAll(update: Partial): this { this.builder.updateAll(update); return this; @@ -132,8 +128,24 @@ export class ProjenStruct extends Component implements IStructBuilder, HasProper this.builder.rename(from, to); return this; } - mixin(...sources: HasProperties[]): this { - this.builder.mixin(...sources); + allOptional(): this { + this.builder.allOptional(); + return this; + } + filter(predicate: (prop: Property) => boolean): this { + this.builder.filter(predicate); + return this; + } + only(...keep: string[]): this { + this.builder.only(...keep); + return this; + } + omit(...remove: string[]): this { + this.builder.omit(...remove); + return this; + } + withoutDeprecated(): this { + this.builder.withoutDeprecated(); return this; } public get properties(): Property[] { diff --git a/test/projen/__snapshots__/projen-struct.test.ts.snap b/test/projen/__snapshots__/projen-struct.test.ts.snap index 5887bc4..4f8631e 100644 --- a/test/projen/__snapshots__/projen-struct.test.ts.snap +++ b/test/projen/__snapshots__/projen-struct.test.ts.snap @@ -1,5 +1,24 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`can map properties 1`] = ` +"// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +/** + * MyInterface + */ +export interface MyInterface { + /** + * This property is required + * @default true + */ + readonly propFour: boolean; + readonly aNumber: number; + readonly aString: string; + readonly aDate: date; +} +" +`; + exports[`can mixin a struct 1`] = ` "// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". @@ -70,6 +89,18 @@ export interface MyInterface { " `; +exports[`can replace a prop 1`] = ` +"// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +/** + * MyInterface + */ +export interface MyInterface { + readonly newProp: number; +} +" +`; + exports[`can update props 1`] = ` "// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". @@ -129,6 +160,29 @@ export interface MyInterface { " `; +exports[`can updateEvery property 1`] = ` +"// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +/** + * MyInterface + */ +export interface MyInterface { + /** + * This property is required + */ + readonly propThree: boolean; + /** + * This property is required + */ + readonly propTwo: boolean; + /** + * This property is required + */ + readonly propOne: boolean; +} +" +`; + exports[`can use struct as type in add 1`] = ` "// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". diff --git a/test/projen/projen-struct.test.ts b/test/projen/projen-struct.test.ts index 714cfb1..518fe68 100644 --- a/test/projen/projen-struct.test.ts +++ b/test/projen/projen-struct.test.ts @@ -278,6 +278,127 @@ test('can rename a prop', () => { expect(renderedFile).toMatchSnapshot(); }); +test('can updateEvery property', () => { + // ARRANGE + const project = new TestProject(); + const spec = { + type: { primitive: PrimitiveType.Boolean }, + optional: false, + docs: { + summary: 'This property is required', + default: 'true', + }, + }; + + // ACT + const struct = new ProjenStruct(project, { name: 'MyInterface' }); + struct.add( + { name: 'propOne', ...spec }, + { name: 'propTwo', ...spec }, + { name: 'propThree', ...spec }, + ).updateEvery((property) => { + if (!property.optional) { + return { + docs: { + default: undefined, + }, + }; + } + return {}; + }); + + // PREPARE + const renderedFile = synthSnapshot(project)['src/MyInterface.ts']; + + // ASSERT + expect(renderedFile).not.toContain('@default true'); + expect(renderedFile).toMatchSnapshot(); +}); + +test('can replace a prop', () => { + // ARRANGE + const project = new TestProject(); + + // ACT + const struct = new ProjenStruct(project, { name: 'MyInterface' }); + struct.add( + { + name: 'oldProp', + type: { primitive: PrimitiveType.Boolean }, + optional: true, + }, + ); + struct.replace('oldProp', { + name: 'newProp', + type: { primitive: PrimitiveType.Number }, + optional: false, + }); + + // PREPARE + const renderedFile = synthSnapshot(project)['src/MyInterface.ts']; + + // ASSERT + expect(renderedFile).not.toContain('oldProp'); + expect(renderedFile).toContain('newProp: number'); + expect(renderedFile).toMatchSnapshot(); +}); + +test('can map properties', () => { + // ARRANGE + const project = new TestProject(); + const spec = { + type: { primitive: PrimitiveType.Boolean }, + optional: false, + docs: { + summary: 'This property is required', + default: 'true', + }, + }; + + // ACT + const struct = new ProjenStruct(project, { name: 'MyInterface' }); + struct.add( + { name: 'propOne', ...spec }, + { name: 'propTwo', ...spec }, + { name: 'propThree', ...spec }, + { name: 'propFour', ...spec }, + ).map((property) => { + switch (property.name) { + case 'propOne': + return { + name: 'aDate', + type: { primitive: PrimitiveType.Date }, + }; + case 'propTwo': + return { + name: 'aString', + type: { primitive: PrimitiveType.String }, + }; + case 'propThree': + return { + name: 'aNumber', + type: { primitive: PrimitiveType.Number }, + }; + default: + return property; + } + }); + + // PREPARE + const renderedFile = synthSnapshot(project)['src/MyInterface.ts']; + + // ASSERT + expect(renderedFile).not.toContain('propOne'); + expect(renderedFile).toContain('aDate'); + expect(renderedFile).not.toContain('propTwo'); + expect(renderedFile).toContain('aString'); + expect(renderedFile).not.toContain('propThree'); + expect(renderedFile).toContain('aNumber'); + expect(renderedFile).toContain('propFour'); + expect(renderedFile).toMatchSnapshot(); +}); + + test('can import type from the same package at the top level', () => { // ARRANGE const project = new TestProject();