1.0.6 • Published 2 years ago

builder-schematic v1.0.6

Weekly downloads
-
License
MIT
Repository
-
Last release
2 years ago

Builder schematic

Intro

This Angular schematic was built to create a "builder" for a TypeScript interface model. According to the definition:

Builder pattern is a design pattern to provide a flexible solution for creating objects. Builder pattern separates the construction of a complex object from its representation. Builder pattern builds a complex object using simple objects by providing a step by step approach. It belongs to the creational patterns.

The constructor of the builder has some defaults, which can be overriden by calling the specific "with" functions by chaining it on that builder instance. When you are ready, you can call "build()" to get an instance of the object. Example:

const myCar: Car = new CarBuilder().with('wheelCount', 4).with('doorCount', 2).build();

Use cases for this:

  • easy mocks for unit testing. By providing defaults in the constructor it gets as short as calling new CarBuilder().build()!
  • data for mock server. E.g. you need 100 cars when calling api/cars you could provide that data by looping for 100 times and pushing new CarBuilder().build() to an array.
  • dummies while coding something. Sometimes you need some quick example data and this is a fast way to provide it.
  • ...

The builder schematic makes it easier to create builders for interface models, especially because you can use fakerjs to create random defaults in the constructor! One small caveat: because references to other interfaces/builders might be in the generated builder, those will not be imported automatically. You'll have to import these manually in the generated builder. But the IDE can probably help you out with this (e.g. VS Code has the option "Add all missing imports")!

One tip to share though: when using these builders with fakerjs inside Jest unit tests with snapshots, make sure to provide a seed to faker in order that the snapshots always stay the same! E.g. faker.seed(123).

Getting started

To generate a builder for a certain model, execute following in the terminal:

ng g builder-schematic:builder
OptionTypeDescription
--filePathstringpath to the file
--outputstringpath to generate the builder in (same folder as file when left empty)
--useFakerbooleanuse faker to create defaults in the constructor

Example of one-liner:

ng g builder-schematic:builder dog.model --output= --useFaker=true

This will create a builder for the Dog interface in the same folder and using fakerJs for the defaults.

Without any options a prompt will follow asking you for the following: 1. File path: the location to the model inside your default app folder (for a default Angular project that is src\app). No need to include the ts extension. Note: Make sure you execute the schematic from the root! 2. The output folder path: the location in which the builder will be placed after generation (root is src). Will be generated in the same folder as the source file if left empty. 3. Use faker: yes to use faker in the construction of the builder or no to leave the properties undefined.

Warning: regenerating the builder will overwrite any changes you may have done to the existing one!

When using faker, the schematic will look at the property names of the model and will try to match them to an appropriate faker function. This is based on a mapping file (JSON) which contains some default mappings (like firstName, lastName,...). You can override this by supplying a builder-config.json file in the root of your project with the following layout:

{
  "ignoreDefaultMappings": false, //optional
  "mappings": [ //required
    {
      "randomizerFn": "faker.company.companyName(0)",
      "matches": ["^companyName"]
    }
  ]
}

ignoreDefaultMappings: when set to true, your config file will be used instead of the default ones. When set to false, your mappings will be checked first, then the default ones. Any mapping you provide will have supercedence! mappings: array containing your custom mappings. Each mapping contains an object having the randomizerFn (pointing to the faker function to use), propertyType (optional; for defining the match based on type) and matches (optional; array of regular expressions to match to the randomizerFn). Note: either propertyType or matches needs to be supplied! The randomizerFn property can also be used to assign any random value or function (e.g. another builder), for example:

{
  "mappings": [
    {
      "randomizerFn": "new CustomBuilder().build()",
      "matches": ["^custom"]
    }
  ]
}

When filling in propertyType ánd matches, the matching will be done on the regular expressions in the matches array ánd the property type. When leaving out the matches property and only supply the propertyType, then matching will occur based on propertyType only. Example:

{
  "mappings": [
    //match based on type Array<number> and regular expression /^companyIds/i
    {
      "randomizerFn": "Array.from({length: faker.datatype.number({min: 1, max: 5})}, () => faker.datatype.number())",
      "propertyType": "Array<number>",
      "matches": ["^companyIds"]
    },
    //match based only on type Array<Shoop>
    {
      "randomizerFn": "Array.from({length: faker.datatype.number({min: 1, max: 5})}, () => new ShoopBuilder().build())",
      "propertyType": "Array<Shoop>"
    }
  ]
}

The default mappings already supplies a default randomizerFn for several property types and faker functions. Have a look at the randomizer-mappings.json file in this lib for more details.

Another way to override the randomizerFn for a property is by supplying a JSDoc tag @randomizerfn to the JSDoc comments of the property in the model. E.g.:

export interface Dog {
  id: number;
  name: string;
  /** @randomizerfn ['sit', 'lay down', 'fetch'] */
  listensTo: string[];  
}

This provides finegrained control per model to what the output can be. The value next to @randomizerfn gets passed through as such, so even functions will work:

export interface Dog {
  id: number;
  /** @randomizerfn 'dog_' + faker.name.firstName() */
  name: string;
  listensTo: string[];  
}

Inheritance

If your interface extends another interface, the generated builder will extend the builder of that parent interface. This is because the properties of the parent cannot be read when generating the builder. Hence the builder for that parent interface will be used instead. Example:

export interface Child extends Parent {
  childName: string;
}

will generate a builder that extends ParentBuilder and in the constructor of ChildBuilder will be a default for the property childName.

import { faker } from '@faker-js/faker';
import { Child } from './child.model';
import { ParentBuilder } from './parent.builder';

export class ChildBuilder extends ParentBuilder {
  protected intermediate: Partial<Child> = this.intermediate as Child;

  constructor() { 
    super();
    this.intermediate.childName = faker.name.findName();
  }

  with<K extends keyof Child>(property: K, value: Child[K]): ChildBuilder {
    this.intermediate[property] = value;
    return this;
  }

  build(): Child {
    const p: Partial<Child> = super.build();
    for (const key of Object.keys(this.intermediate)) {
      p[key] = this.intermediate[key];
    }
    return p as Child;
  }
}

That's why it's easier to first generate the builder of the parent interface and then generate the child interface.