From 769f934177ff9da4d7b5ff198473d459798858fd Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Sun, 31 Jul 2022 20:04:43 +0300 Subject: [PATCH] chore: support extension through abstract base class (#18) This is a proposal based on [this comment](https://github.com/monadahq/polycons/pull/15/files#r933560022). The basic idea is that both the polycon class (e.g. `Dog`) and the concrete class (`Poodle`) extend a common abstract base type (e.g. `DogBase`). This has the following effects: 1. No need to protect against stack overflow (the concrete class doesn't actually extend the polycon). 2. It is now possible to instantiate any concrete type directly (added a test that instantiates a `Labrador` even if when `Dog` resolves to `Poodle`. 3. We can still share common implementation at the base. 4. Enforce that concrete classes actually implement all abstract methods and properties (previously this was only by convention). The polycon constructor has a quirky pattern: ```ts constructor(scope, id, props) { super(null as any, id, props); return Polycons.create(QUALIFIER, scope, id, props); } ``` The call to `super()` with `null` as the `scope` does two things: 1. The base construct is not attached to our tree. 2. The `null` can be used by the base class to bail out early: ```ts class DogBase { public readonly foo: number; constructor(scope, id, props) { super(scope, id); if (!scope) { // initialize all readonly props with dummy values, this obj is thrown away this.foo = -1; return; } this.foo = 7947; // <-- the real thing } } ``` --- API.md | 152 ++++++++++++----------------------------- src/index.ts | 1 - src/polycon-factory.ts | 20 ++++++ src/polycon.ts | 48 ------------- test/polycon.test.ts | 102 ++++++++++++++++++++++++--- 5 files changed, 154 insertions(+), 169 deletions(-) delete mode 100644 src/polycon.ts diff --git a/API.md b/API.md index acde5f3..3cd72c3 100644 --- a/API.md +++ b/API.md @@ -1,114 +1,5 @@ # API Reference -## Constructs - -### Polycon - -A polymorphic construct that can be resolved at construction time into a more specific construct. - -#### Initializers - -```typescript -import { Polycon } from '@monadahq/polycons' - -new Polycon(qualifier: string, scope: Construct, id: string, props?: any) -``` - -| **Name** | **Type** | **Description** | -| --- | --- | --- | -| qualifier | string | *No description.* | -| scope | constructs.Construct | *No description.* | -| id | string | *No description.* | -| props | any | *No description.* | - ---- - -##### `qualifier`Required - -- *Type:* string - ---- - -##### `scope`Required - -- *Type:* constructs.Construct - ---- - -##### `id`Required - -- *Type:* string - ---- - -##### `props`Optional - -- *Type:* any - ---- - -#### Methods - -| **Name** | **Description** | -| --- | --- | -| toString | Returns a string representation of this construct. | - ---- - -##### `toString` - -```typescript -public toString(): string -``` - -Returns a string representation of this construct. - -#### Static Functions - -| **Name** | **Description** | -| --- | --- | -| isConstruct | Checks if `x` is a construct. | - ---- - -##### ~~`isConstruct`~~ - -```typescript -import { Polycon } from '@monadahq/polycons' - -Polycon.isConstruct(x: any) -``` - -Checks if `x` is a construct. - -###### `x`Required - -- *Type:* any - -Any object. - ---- - -#### Properties - -| **Name** | **Type** | **Description** | -| --- | --- | --- | -| node | constructs.Node | The tree node. | - ---- - -##### `node`Required - -```typescript -public readonly node: Node; -``` - -- *Type:* constructs.Node - -The tree node. - ---- - ## Structs @@ -445,11 +336,54 @@ public resolveConstruct(qualifier: string, scope: IConstruct, id: string, props? | **Name** | **Description** | | --- | --- | +| newInstance | Creates a new instance of a polycons by resolving it through the registered factory. | | of | Returns the polycon factory registered in a given scope. | | register | Adds a factory at the root of the construct tree. | --- +##### `newInstance` + +```typescript +import { PolyconFactory } from '@monadahq/polycons' + +PolyconFactory.newInstance(qualifier: string, scope: IConstruct, id: string, props?: any) +``` + +Creates a new instance of a polycons by resolving it through the registered factory. + +###### `qualifier`Required + +- *Type:* string + +The type qualifier. + +--- + +###### `scope`Required + +- *Type:* constructs.IConstruct + +The construct scope. + +--- + +###### `id`Required + +- *Type:* string + +The construct identifier. + +--- + +###### `props`Optional + +- *Type:* any + +The construct props. + +--- + ##### `of` ```typescript diff --git a/src/index.ts b/src/index.ts index c17618f..be573f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ export * from "./process"; -export * from "./polycon"; export * from "./polycon-factory"; diff --git a/src/polycon-factory.ts b/src/polycon-factory.ts index 5d305fa..dbca2ff 100644 --- a/src/polycon-factory.ts +++ b/src/polycon-factory.ts @@ -41,6 +41,26 @@ export abstract class PolyconFactory { }); } + /** + * Creates a new instance of a polycons by resolving it through the registered + * factory. + * + * @param qualifier The type qualifier + * @param scope The construct scope + * @param id The construct identifier + * @param props The construct props + * @returns The resolved construct + */ + public static newInstance( + qualifier: string, + scope: IConstruct, + id: string, + props?: any + ) { + const factory = PolyconFactory.of(scope); + return factory.resolveConstruct(qualifier, scope, id, props); + } + public abstract resolveConstruct( qualifier: string, scope: IConstruct, diff --git a/src/polycon.ts b/src/polycon.ts deleted file mode 100644 index 9bd7e15..0000000 --- a/src/polycon.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Construct } from "constructs"; -import { PolyconFactory } from "./polycon-factory"; - -/** - * A polymorphic construct that can be resolved at construction time into a more - * specific construct. - */ -export abstract class Polycon extends Construct { - protected constructor( - qualifier: string, - scope: Construct, - id: string, - props?: any - ) { - // check if we are being called from a polycon resolution code path - // this is done by checking if a marker for this polycon is present in the - // scope. if so, we will initialize this as an empty construct and delete - // the marker - const marker = Symbol.for(`polycons.init[${qualifier}]#${id}`); - if (marker in scope) { - super(scope, id); - delete (scope as any)[marker]; // delete the marker - return this; - } - - // since we eventually return the resolved polycon, we can just initialize - // the base class as an empty root construct (it won't be used) - super(null as any, ""); - - const factory = PolyconFactory.of(scope); - if (!factory) { - throw new Error(`No factory defined within scope of "${id}"`); - } - - // add the initialization marker to avoid re-entering this path - // when the resolved polycon is initialized. - Object.defineProperty(scope, marker, { - value: true, - enumerable: false, - writable: false, - configurable: true, // we are deleting the marker after construction - }); - - const resolved = factory.resolveConstruct(qualifier, scope, id, props); - - return resolved as Polycon; - } -} diff --git a/test/polycon.test.ts b/test/polycon.test.ts index 89c097c..b4f9ae0 100644 --- a/test/polycon.test.ts +++ b/test/polycon.test.ts @@ -1,5 +1,5 @@ import { Construct, IConstruct } from "constructs"; -import { Polycon, PolyconFactory } from "../src"; +import { PolyconFactory } from "../src"; test("polycon creation marker is deleted from the scope", () => { const app = new App(); @@ -106,6 +106,38 @@ test("factory is able to make decisions based on the id of the polycon", () => { expect(special instanceof Labrador).toBeTruthy(); }); +test("factory is able to change props passed into the polycon", () => { + const app = new App(); + PolyconFactory.register(app, new PoodleFactory()); + const special = new Dog(app, "labrador", { name: "shmo", treats: 3 }); + + // factory lets labradors get twice the number of treats + expect(special.treats).toEqual(6); + expect(special.toString()).toEqual("Labrador with 6 treats."); +}); + +test("polycon constructor does not get called more than once", () => { + const app = new App(); + PolyconFactory.register(app, new PoodleFactory()); + const piffle = new Dog(app, "piffle", { name: "piffle", treats: 5 }); + const biffle = new Dog(app, "biffle", { + name: "biffle", + treats: 5, + friends: [piffle], + }); + + expect(biffle.friendCount).toEqual(1); + expect(piffle.friendCount).toEqual(1); +}); + +test("concretes can be defined explicitly", () => { + const app = new App(); + PolyconFactory.register(app, new PoodleFactory()); + const lab = new Labrador(app, "my_lab", { name: "lab", treats: 5 }); + expect(lab.toString()).toEqual("Labrador with 5 treats."); + expect(app.synth()).toStrictEqual(["root", "root/my_lab"]); +}); + class App extends Construct { constructor() { super(undefined as any, "root"); @@ -122,36 +154,69 @@ const DOG_QUALIFIER = "test.dog"; interface DogProps { readonly name: string; readonly treats: number; + readonly friends?: Dog[]; } -class Dog extends Polycon { +abstract class DogBase extends Construct { public readonly species = "Canis familiaris"; public readonly treats: number; + public friendCount: number; + constructor(scope: Construct, id: string, props: DogProps) { - super(DOG_QUALIFIER, scope, id, props); + super(scope, id); + if (!scope) { + // initialized through the polycon, just dummy values + this.friendCount = 0; + this.treats = 0; + return; + } this.treats = props.treats; + this.friendCount = 0; + + for (const friend of props.friends ?? []) { + this.addFriend(); + friend.addFriend(); + } + } + + public addFriend() { + this.friendCount += 1; } public toStringUppercase() { return this.toString().toUpperCase(); } + public abstract toString(): string; +} + +class Dog extends DogBase { + constructor(scope: Construct, id: string, props: DogProps) { + super(null as any, id, props); + return PolyconFactory.newInstance(DOG_QUALIFIER, scope, id, props) as Dog; + } + public toString(): string { throw new Error("unimplemented"); } } -class Poodle extends Dog { - public readonly treats: number; +class Poodle extends DogBase { constructor(scope: Construct, id: string, props: DogProps) { super(scope, id, props); - this.treats = props.treats; } public toString() { return `Poodle with ${this.treats} treats.`; } } -class Labrador extends Dog {} +class Labrador extends DogBase { + constructor(scope: Construct, id: string, props: DogProps) { + super(scope, id, props); + } + public toString() { + return `Labrador with ${this.treats} treats.`; + } +} // == cat data structures == @@ -161,16 +226,28 @@ interface CatProps { readonly scritches: number; } -class Cat extends Polycon { +class CatBase extends Construct { + constructor(scope: Construct, id: string, props: CatProps) { + super(scope, id); + if (!scope) { + return; + } + + props; + } +} + +class Cat extends CatBase { constructor(scope: Construct, id: string, props: CatProps) { - super(CAT_QUALIFIER, scope, id, props); + super(null as any, id, props); + return PolyconFactory.newInstance(CAT_QUALIFIER, scope, id, props) as Cat; } public toString(): string { throw new Error("unimplemented"); } } -class Shorthair extends Cat { +class Shorthair extends CatBase { public readonly scritches: number; constructor(scope: Construct, id: string, props: CatProps) { super(scope, id, props); @@ -193,7 +270,10 @@ class PoodleFactory extends PolyconFactory { switch (qualifier) { case DOG_QUALIFIER: if (id === "labrador") { - return new Labrador(scope, id, props); + return new Labrador(scope, id, { + ...props, + treats: props.treats * 2, + }); } return new Poodle(scope, id, props); default: