diff --git a/API.md b/API.md index fced5d58..d961cbd9 100644 --- a/API.md +++ b/API.md @@ -51,6 +51,7 @@ dash `--`. | **Name** | **Description** | | --- | --- | | toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | --- @@ -62,6 +63,27 @@ public toString(): string Returns a string representation of this construct. +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...IMixin[] + +The mixins to apply. + +--- + #### Static Functions | **Name** | **Description** | @@ -161,6 +183,7 @@ dash `--`. | **Name** | **Description** | | --- | --- | | toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | --- @@ -172,6 +195,27 @@ public toString(): string Returns a string representation of this construct. +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...IMixin[] + +The mixins to apply. + +--- + #### Static Functions | **Name** | **Description** | @@ -1066,6 +1110,32 @@ Separator used to delimit construct path components. Represents a construct. +#### Methods + +| **Name** | **Description** | +| --- | --- | +| with | Applies one or more mixins to this construct. | + +--- + +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. + +###### `mixins`Required + +- *Type:* ...IMixin[] + +The mixins to apply. + +--- #### Properties @@ -1103,6 +1173,50 @@ deployed. +### IMixin + +- *Implemented By:* IMixin + +A mixin is a reusable piece of functionality that can be applied to constructs to add behavior, properties, or modify existing functionality without inheritance. + +#### Methods + +| **Name** | **Description** | +| --- | --- | +| applyTo | Applies the mixin functionality to the target construct. | +| supports | Determines whether this mixin can be applied to the given construct. | + +--- + +##### `applyTo` + +```typescript +public applyTo(construct: IConstruct): void +``` + +Applies the mixin functionality to the target construct. + +###### `construct`Required + +- *Type:* IConstruct + +--- + +##### `supports` + +```typescript +public supports(construct: IConstruct): boolean +``` + +Determines whether this mixin can be applied to the given construct. + +###### `construct`Required + +- *Type:* IConstruct + +--- + + ### IValidation - *Implemented By:* IValidation diff --git a/src/construct.ts b/src/construct.ts index d0d14e34..fb5da567 100644 --- a/src/construct.ts +++ b/src/construct.ts @@ -1,6 +1,7 @@ import type { IDependable } from './dependency'; import { Dependable } from './dependency'; import type { MetadataEntry } from './metadata'; +import type { IMixin } from './mixin'; import { captureStackTrace } from './private/stack-trace'; import { addressOf } from './private/uniqueid'; @@ -14,6 +15,17 @@ export interface IConstruct extends IDependable { * The tree node. */ readonly node: Node; + + /** + * Applies one or more mixins to this construct. + * + * Mixins are applied in order. The list of constructs is captured at the + * start of the call, so constructs added by a mixin will not be visited. + * + * @param mixins The mixins to apply + * @returns This construct for chaining + */ + with(...mixins: IMixin[]): IConstruct; } /** @@ -505,6 +517,29 @@ export class Construct implements IConstruct { }); } + /** + * Applies one or more mixins to this construct. + * + * Mixins are applied in order. The list of constructs is captured at the + * start of the call, so constructs added by a mixin will not be visited. + * Use multiple `with()` calls if subsequent mixins should apply to added + * constructs. + * + * @param mixins The mixins to apply + * @returns This construct for chaining + */ + public with(...mixins: IMixin[]): IConstruct { + const allConstructs = this.node.findAll(); + for (const mixin of mixins) { + for (const construct of allConstructs) { + if (mixin.supports(construct)) { + mixin.applyTo(construct); + } + } + } + return this; + }; + /** * Returns a string representation of this construct. */ diff --git a/src/index.ts b/src/index.ts index 06c09a8d..f4360fe8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './construct'; export type * from './metadata'; -export * from './dependency'; \ No newline at end of file +export type * from './mixin'; +export * from './dependency'; diff --git a/src/mixin.ts b/src/mixin.ts new file mode 100644 index 00000000..0437cce8 --- /dev/null +++ b/src/mixin.ts @@ -0,0 +1,17 @@ +import type { IConstruct } from './construct'; + +/** + * A mixin is a reusable piece of functionality that can be applied to constructs + * to add behavior, properties, or modify existing functionality without inheritance. + */ +export interface IMixin { + /** + * Determines whether this mixin can be applied to the given construct. + */ + supports(construct: IConstruct): boolean; + + /** + * Applies the mixin functionality to the target construct. + */ + applyTo(construct: IConstruct): void; +} diff --git a/test/construct.test.ts b/test/construct.test.ts index f0502cf4..a94692f7 100644 --- a/test/construct.test.ts +++ b/test/construct.test.ts @@ -688,3 +688,160 @@ interface ValidationError { readonly source: IConstruct; readonly message: string; } + +describe('with() mixin support', () => { + test('returns the construct for chaining', () => { + const root = new Root(); + const result = root.with(); + expect(result).toBe(root); + }); + + test('applies a single mixin to the construct', () => { + const root = new Root(); + const applied: IConstruct[] = []; + const mixin = { + supports: () => true, + applyTo: (c: IConstruct) => applied.push(c), + }; + + root.with(mixin); + + expect(applied).toContain(root); + }); + + test('applies multiple mixins to the construct', () => { + const root = new Root(); + const applied1: IConstruct[] = []; + const applied2: IConstruct[] = []; + const mixin1 = { + supports: () => true, + applyTo: (c: IConstruct) => applied1.push(c), + }; + const mixin2 = { + supports: () => true, + applyTo: (c: IConstruct) => applied2.push(c), + }; + + root.with(mixin1, mixin2); + + expect(applied1).toContain(root); + expect(applied2).toContain(root); + }); + + test('applies mixin to all children in the tree', () => { + const root = new Root(); + const child1 = new Construct(root, 'child1'); + new Construct(root, 'child2'); + new Construct(child1, 'grandchild'); + + const applied: string[] = []; + const mixin = { + supports: () => true, + applyTo: (c: IConstruct) => applied.push(c.node.id || 'root'), + }; + + root.with(mixin); + + expect(applied).toEqual(['root', 'child1', 'grandchild', 'child2']); + }); + + test('only applies mixin to constructs that pass supports() check', () => { + const root = new Root(); + new Construct(root, 'child1'); + new Construct(root, 'child2'); + + const applied: string[] = []; + const mixin = { + supports: (c: IConstruct) => c.node.id === 'child1', + applyTo: (c: IConstruct) => applied.push(c.node.id), + }; + + root.with(mixin); + + expect(applied).toEqual(['child1']); + }); + + test('does not apply mixin when supports() returns false for all', () => { + const root = new Root(); + new Construct(root, 'child1'); + + const applied: IConstruct[] = []; + const mixin = { + supports: () => false, + applyTo: (c: IConstruct) => applied.push(c), + }; + + root.with(mixin); + + expect(applied).toHaveLength(0); + }); + + test('supports chaining multiple with() calls', () => { + const root = new Root(); + const applied1: IConstruct[] = []; + const applied2: IConstruct[] = []; + const mixin1 = { + supports: () => true, + applyTo: (c: IConstruct) => applied1.push(c), + }; + const mixin2 = { + supports: () => true, + applyTo: (c: IConstruct) => applied2.push(c), + }; + + root.with(mixin1).with(mixin2); + + expect(applied1).toContain(root); + expect(applied2).toContain(root); + }); + + test('mixin can modify construct metadata', () => { + const root = new Root(); + const mixin = { + supports: () => true, + applyTo: (c: IConstruct) => c.node.addMetadata('mixin-applied', true), + }; + + root.with(mixin); + + expect(root.node.metadata).toEqual([{ type: 'mixin-applied', data: true, trace: undefined }]); + }); + + test('applies mixins in order, completing each mixin before the next', () => { + const root = new Root(); + new Construct(root, 'child'); + + const order: string[] = []; + const mixin1 = { + supports: () => true, + applyTo: (c: IConstruct) => order.push(`m1:${c.node.id || 'root'}`), + }; + const mixin2 = { + supports: () => true, + applyTo: (c: IConstruct) => order.push(`m2:${c.node.id || 'root'}`), + }; + + root.with(mixin1, mixin2); + + expect(order).toEqual(['m1:root', 'm1:child', 'm2:root', 'm2:child']); + }); + + test('does not apply mixins to constructs added by other mixins', () => { + const root = new Root(); + + const applied: string[] = []; + const addingMixin = { + supports: (c: IConstruct) => c.node.id === '', + applyTo: (c: IConstruct) => new Construct(c, 'added-by-mixin'), + }; + const trackingMixin = { + supports: () => true, + applyTo: (c: IConstruct) => applied.push(c.node.id || 'root'), + }; + + root.with(addingMixin, trackingMixin); + + expect(applied).toEqual(['root']); + expect(root.node.findChild('added-by-mixin')).toBeDefined(); + }); +});