@dgcode/field-manager
TypeScript icon, indicating that this package has built-in type declarations

0.1.54 • Public • Published

@dgcode/field-manager

utility class and decorator for maintaining field definitions

Install

$ npm install @dgcode/field-manager

While compatible with JavaScript, field-manager is mainly designed to work with TypeScript. Make sure to enable { "experimentalDecorators": true } in the compilerOptions of your tsconfig.json file.

Usage

import { Field } from '@dgcode/field-manager';

class Vehicle {

  @Field() type: string;
  @Field() wheels: number;

}

console.log(Field.names(Vehicle)); // ['type', 'wheels']

Field metadata

The main purpose of this library is to attach arbitrary data to any field we like. For example, if we wanted to attach specific content to our type field (marking it with { hello: 'world' }) and our wheels field (marking it with { foo: 5 }):

import { Field } from '@dgcode/field-manager';

class Vehicle {

  @Field({ hello: 'world' }) type: string;
  @Field({ foo: 5 }) wheels: number;

}

console.log(Field.names(Vehicle)); // ['type', 'wheels']

Plain JavaScript usage

While TypeScript is strongly recommended when using this library, the patterns above can be achieved in plain JavaScript this way:

const { Field } = require('@dgcode/field-manager');

class Vehicle {}

Field.decorate(Vehicle, 'type');
Field.decorate(Vehicle, 'wheels', { foo: 5 });

console.log(Field.names(Vehicle)); // ['type', 'wheels']

Advanced usage: FieldManager

A field manager is a central object maintained along with your decorated classes / instances and that keeps track of all defined fields and options. From the first time you run @Field() on a class' member, a field manager is maintained along your class.

The most convenient way to access a FieldManager instance is shown below:

import { Field } from '@dgcode/field-manager';

class Vehicle {

  @Field() type: string;
  @Field() wheels: number;

}

const manager = Field.manager(Vehicle);
const manager = Field.manager(Vehicle, false); // variant, can return `null` if @Field was never called on Vehicle

or alternatively:

import { Field, FieldManager } from '@dgcode/field-manager';

class Vehicle {

  @Field() type: string;
  @Field() wheels: number;

}

const manager = FieldManager.fetch(Vehicle); // can be `null` if @Field was never called on Vehicle
const manager = FieldManager.upsert(Vehicle); // variant, always returns a manager

Once you have access to a FieldManager object, you can run its following helper methods to read metadata about your fields:

List all decorated field names in an array

manager.listFieldNames();
// [ 'type', 'wheels' ]

manager.fieldNames(); // similar, as an Iterator object

List all options passed to decorated field objects

Note that each object listed this way also implements a { fieldName } property corresponding to said name.

manager.listFields();
// [ { fieldName: 'type' }, { fieldName: 'wheels' } ]

manager.fields(); // similar, as an Iterator object

In any additional metadata was passed earlier via the @Field(data) decorator, those keys will be available again in the listed fields. See below:

class Vehicle {

  @Field({ hello: 'world' }) type: string;
  @Field({ foo: 5 }) wheels: number;

}

const manager = Field.manager(Vehicle);
manager.listFields();
// [ { fieldName: 'type', hello: 'world' }, { fieldName: 'wheels', foo: 5 } ]

Count how many fields have been decorated

manager.count();
// 2

Check if a specific field name has been decorated

manager.has('wheels'); // true
manager.has('someUnknownField'); // false

Get the whole options object passed for a specific known field

manager.data('type');
// { fieldName: 'type' }

In any additional metadata was passed ealier via the @Field(data) decorator, those keys will be available again in the returned object. See below:

class Vehicle {

  @Field({ hello: 'world' }) type: string;
  @Field({ foo: 5 }) wheels: number;

}

const manager = Field.manager(Vehicle);
manager.data('wheels');
// { fieldName: 'wheels', foo: 5 }

Note this operation is sensitive: ensure the field name is defined first via .has(fieldName). If calling .data(...) on an unknown field name, this method directly throws an Error.

Get a value from an options object passed for a specific known field

manager.data('type', 'foo');
// 5

This is eventually similar to:

manager.data('type').foo;
// 5

This method is only relevant if you pass additional metadata information to your field calls, such as:

class Vehicle {

  @Field({ hello: 'world' }) type: string;
  @Field({ foo: 5 }) wheels: number;

}

Note this operation is sensitive: ensure the field name is defined first via .has(fieldName). If calling .data(...) on an unknown field name, this method directly throws an Error.

Extending

field-manager is designed to be customizable for your own library / application needs without too much hassle.

Generic idea

The magic Field function provided by this library can be duplicated and customized. It comes with a set of tools to achieve that, the main one being the .fork(...) function:

const HelloField = Field.fork();

Once generated, your new function can be called just like any other Field decorator:

class Vehicle {

  @HelloField() type: string;
  @HelloField() wheels: number;

}

or in regular JavaScript:

class Vehicle {}
HelloField.decorate(Vehicle, 'type');
HelloField.decorate(Vehicle, 'wheels');

Default options for decorator calls

Usage:

const HelloField = Field.fork({ hello: 'world' });

Alternative usage (alter defaults once function is already generated):

const HelloField = Field.fork();
HelloField.defaults.hello = 'world';

Doing this ensures that options objects you pass to each decorated field implement at least these properties, even when unspecified. Example:

class Vehicle {

  @HelloField() type: string;
  // ^ implicitly `@HelloField({ hello: 'world' })`

  @HelloField() wheels: number;
  // ^ also implicitly `@HelloField({ hello: 'world' })`

  @HelloField({ hello: 'foobar' }) motor: string;
  // ^ in this case though `hello` is explicitly passed and overrides the default

}

Keep in mind you can pass any number of keys as your field options. Only the relevant keys will be default'ed when not found in your calls.

const HelloField = Field.fork({ hello: 'world' });

class Vehicle {

  @HelloField() type: string;
  // ^ implicitly `@HelloField({ hello: 'world' })`

  @HelloField({ someData: 5 }) wheels: number;
  // ^ implicitly `@HelloField({ hello: 'world', someData: 5 })`

  @HelloField({ hello: 'foobar', someData: 8 }) motor: string;
  // ^ all keys specified here

}

Chaining defaults for shared fields

As showcased above, having multiple decorator functions means we can decorate each individual field in multiple ways:

const HelloField = Field.fork({ hello: 'world' });
const ColorField = Field.fork({ color: 'white' });

class Vehicle {

  @HelloField() @ColorField()
  type: string;

  @HelloField() @ColorField({ color: 'yellow' })
  wheels: number;

  @HelloField() motor: string;
  // ^ this one is not colored!

}

With this example above, all fields tagged this way will share the same FieldManager instance assigned to Vehicle:

HelloField.manager(Vehicle) === ColorField.manager(Vehicle); // true
HelloField.manager(Vehicle) === Field.manager(Vehicle); // also true

If we inspect the overall data assigned to each field, this will lead to:

Field.manager(Vehicle).listFields();
// [
//   { fieldName: 'type', hello: 'world', color: 'white' },
//   { fieldName: 'wheels', hello: 'world', color: 'yellow' },
//   { fieldName: 'motor', hello: 'world' }
// ]

Separation of concerns with multiple FieldManagers

Sometimes storing all data in a single object can bloat information, lead to tight coupling, and make things hard to maintain overall. To the point where we want to explicitly maintain different stores depending on the field kind.

If we re-use our previous example:

const HelloField = Field.fork({ hello: 'world' });
const ColorField = Field.fork({ color: 'white' });

class Vehicle {

  @HelloField() @ColorField()
  type: string;

  @HelloField() @ColorField({ color: 'yellow' })
  wheels: number;

  @HelloField()
  motor: string;

}

both HelloField and ColorField will share the same FieldManager assigned to Vehicle.

HelloField.manager(Vehicle) === ColorField.manager(Vehicle); // true
ColorField.names(Vehicle);
// [ 'type', 'wheels', 'motor' ]

We can see that 'motor' has been included even if it has never been tagged with the @ColorField() decorator. Checking color-tagged fields requires explicitly filtering them:

ColorField.names(Vehicle).filter(fieldName => !!ColorField.data(Vehicle, fieldName, 'color'));
// [ 'type', 'wheels' ]

To properly split fields by tag, we have to maintain a separate manager class in charge of color-tagged fields:

class ColorFieldManager extends FieldManager {};

const HelloField = Field.fork({ hello: 'world' });
const ColorField = Field.fork(ColorFieldManager, { color: 'white' });

class Vehicle {

  @HelloField() @ColorField()
  type: string;

  @HelloField() @ColorField({ color: 'yellow' })
  wheels: number;

  @HelloField()
  motor: string;

}

From now on, all fields tagged with @ColorField() will be stored in an instance of ColorFieldManager, no longer in a classic FieldManager.

ColorField.manager(Vehicle) === HelloField.manager(Vehicle); // false
ColorField.names(Vehicle); // [ 'type', 'wheels', 'motor' ]
HelloField.names(Vehicle); // [ 'type', 'wheels' ]

This also implies that field data specific to each tag is now completely separate from each other. In other words, HelloField will no longer be aware of the color property, and ColorField won't be aware of hello either:

Field.manager(Vehicle).listFields();
HelloField.manager(Vehicle).listFields();
// [
//   { fieldName: 'type', hello: 'world' },
//   { fieldName: 'wheels', hello: 'world' },
//   { fieldName: 'motor', hello: 'world' }
// ]
ColorField.manager(Vehicle).listFields();
// [
//   { fieldName: 'type', color: 'white' },
//   { fieldName: 'wheels', color: 'yellow' }
// ]

New field definition hooks

If you implement your own FieldManager classes, as in the previous example above:

class ColorFieldManager extends FieldManager {};

then you are now also free to detect any new field decoration / update. You have to design the class beforehand to handle these events, with help of the following hooks:

  • onNewField(fieldData, owner) when a brand new field name is defined;
  • onUpdateField(fieldData, owner) when information about an existing field name has been updated.

In each case:

  • fieldData is the full field data object. It notably implements the { fieldName } information if desired.
  • owner is the current owner of the field. This is very likely the .prototype of a class (e.g. Vehicle.prototype), or in some cases a regular object if the decorated field holder is an instance.

Example implementation

Here we generate a field decorator @IntField which ensures that the decorated field is always assigned as an integer (a TypeError is thrown otherwise).

class IntFieldManager extends FieldManager {

  // hook implementation when a new field is set
  onNewField(fieldData, prototype) {

    const { fieldName } = fieldData;
    const privateKey = `__${fieldName}`;

    Object.defineProperty(prototype, fieldName, {
      get() {
        return this[privateKey];
      },
      set(value) {
        if ('number' !== typeof value || Math.round(value) !== value) {
          throw new TypeError(`Integer expected for field "${fieldName}"`);
        }
        this[privateKey] = value
      }
    });

  }

}

Now that the FieldManager is defined, bind it to a custom @Field implementation, which we name @Int below:

const Int = Field.fork(IntFieldManager);

class Person {

  @Int()
  age: number;

  @Int()
  id: number;

}

We can now check that the fields apply properly by checking a few persons:

const joe = new Person();
joe.age = 5; // ok
joe.id = 'foobar'; // TypeError: Integer expected for field "id"

License

MIT

Readme

Keywords

none

Package Sidebar

Install

npm i @dgcode/field-manager

Weekly Downloads

1

Version

0.1.54

License

MIT

Unpacked Size

65.6 kB

Total Files

18

Last publish

Collaborators

  • dgcoder