Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions API.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions src/construct.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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.
*/
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './construct';
export type * from './metadata';
export * from './dependency';
export type * from './mixin';
export * from './dependency';
17 changes: 17 additions & 0 deletions src/mixin.ts
Original file line number Diff line number Diff line change
@@ -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;
}
157 changes: 157 additions & 0 deletions test/construct.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading