0.0.20 • Published 2 years ago

mozel v0.0.20

Weekly downloads
136
License
ISC
Repository
github
Last release
2 years ago

Mozel

A Mozel is a strongly-typed model, which ensures that its properties are of the correct type, both at runtime and compile-time (Typescript). It is easy to define a Mozel, and it brings a number of useful features.

Mozel can be used both in Typescript and plain Javascript.

Features

  • Nested models
  • Strongly-typed properties and collections (both compile-time and runtime)
  • Simple Typescript declarations
  • Deep change watching
  • Import/export from/to plain objects - allows easy transmission between systems through JSON.
  • Deep string templating (e.g. {"name": "{username}"})

Getting Started

Definition

A Mozel definition is simple and can be done both in Typescript and plain Javascript:

Typescript:

import Mozel, {required} from "mozel";

class Person extends Mozel {
    @property(String, {required, default: 'John Doe'}) // runtime typing
    name!:string; // compile-time (Typescript) typing
    
    @collection(String) // runtime typing
    nicknames!:Collection<String>; // compile-time (Typescript) typing
}

Note that, if you set {required} on a @property, you can safely assume the value will never be undefined. In Typescript, you can therefore use ! with the property. Without {required}, you should use ?. Collections are always instantiated and therefore will never be undefined.

Javascript:

import Mozel, {required} from "mozel";

class Person extends Mozel {}
Person.property('name', String, {required});
Person.collection('nicknames', String);

To set a default, use {default: ...} in the property options, instead of myProperty:string = 'myDefault'.

Instantiation

A Mozel can be instantiated with data using the static create method. The instantiation is the same for Javascript and Typescript, but in Typescript, the plain instantiation object will be strongly typed according to the Mozel's properties.

let person = Person.create({
    name: 'James',
    nicknames: ['Johnny', 'Jack']
});

console.log(person.name); // James
console.log(person.nicknames.toArray()); // ['Johnny', 'Jack']

Only valid values will be accepted:

person.name = 123;
console.log(person.name); // still 'James'

Type definitions

In Typescript, each property has runtime type definition, as well as a Typescript type definition. These should always match for the mozel to be considered type-safe.

Examples:

Runtime definitionTypescript definitionDescription
@property(String)foo?:stringOptional string
@property(Number)foo?:numberOptional number
@property(Alphanumeric)foo?:alphanumericOptional string or number
@property(MyMozel)foo?:MyMozelOptional instance of MyMozel
@collection(String)foo!:Collection<string>Collection of strings*
@collection(MyMozel)foo!:Collection<MyMozel>Collection of MyMozels*
@property(String, {required})foo!:stringRequired string, defaults to emtpy string
@property(MyMozel, {required})foo!:MyMozelRequired instsance of MyMozel, generates a default MyMozel if empty.
@property(String, {required, default: "foo"})foo!:stringRequired string, defaults to "foo"
@property(String, {required, default: ()=>barfoo!:stringRequired string, defaults to the return value of the given function if empty.

* Collections are always defined at initialization, even if empty. It is therefore safe to place the ! in the Typescript definition.

Nested Mozels

Properties can be either primitive, or other Mozels. Nested Mozels can be instantiated entirely by providing nested data to the create method, or partially by providing existing Mozels for the nested data.

// Definitions

class Dog extends Mozel {
    @property(String, {required})
    name!:string;
}
class Person extends Mozel {
    @property(String, {required})
    name!:string;

    @property(Dog)
    dog?:Dog;
}

// Instances

let bobby = Dog.create({
    name: 'Bobby'
});

// Lisa has an existing dog
let lisa = Person.create({
    name: 'Lisa',
    dog: bobby
})

// James has a new dog
let james = Person.create({
    name: 'James',
    dog: { // will create a new Dog called Baxter
        name: 'Baxter'
    }
});

console.log(lisa.dog instanceof Dog); //true
console.log(lisa.dog.name); // Bobby
console.log(james.dog instanceof Dog); // true
console.log(james.dog.name); // Baxter

Collections

Collections can contain primitives (string/number/boolean) or Mozels. The definition determines which type all items in the collection should be. A Collection on a Mozel will always be instantiated with the Mozel, so it cannot be undefined.

// Definitions 

class Dog extends Mozel {
    @property(String, {required})
    name!:string;
}
class Person extends Mozel {
    @property(String, {required})
    name!:string;
    
    @collection(Dog)
    dogs!:Collection<Dog>;
}

// Instances

let james = Person.create({
    name: 'James',
    dogs: [{name: 'Baxter'}, {name: 'Bobby'}]
});

console.log(james.dogs.get(0) instanceof Dog); // true
console.log(james.dogs.map(dog => dog.name)); // ['Baxter', 'Bobby'] 

Transferral

A Mozel can only have one parent (although multiple Mozels can reference it). If it is transferred from one parent to another, the original parent's property is automatically set to undefined.

let baxter = Dog.create({name: 'Baxter'});
let james = Person.create({
    name: 'James',
    dog: baxter
})
let lisa = Person.create({name: 'Lisa'});

// James has the dog
console.log(james.dog.name); // baxter
console.log(lisa.dog); // undefined

// Transfer to Lisa
lisa.dog = baxter;

// Lisa has the dog; James no longer has a dog
console.log(james.dog); // undefined
console.log(lisa.dog.name); // baxter

If the same Mozel needs to be accessed from multiple other Mozels, only one Mozel can be the parent and the others can have references to it:

class Dog extends Mozel {
	@property(String, {required})
    name!:string;
}
class Person extends Mozel {	
	@property(Dog) 
    dog?:Dog
  
    @property(Dog)
    favourite?:Dog
}

let baxter = Dog.create({name: 'Baxter'});
let james = Person.create({
    dog: baxter
});
let lisa = Person.create();

// James has the dog
console.log(james.dog.name); // Baxter

// Lisa's favourite dog is Baxter
lisa.favourite = baxter;

// James still has the dog, but Lisa also has a reference
console.log(james.dog.name); // Baxter
console.log(lisa.dogSits.name); // Baxter

To initialize a reference together with the same data that creates the referenced instance, use _id:

let james = Person.create({
    name: 'James',
    dog: {
        _id: 'baxter',
        name: 'Baxter'
    },
    favourite: { _id: 'baxter' }
})

References will be resolved directly after all Mozels have been created.

Import/export

The import/export feature makes it easy to transmit a Mozel as plain object data or JSON to another system, and have it reconstructed into a Mozel on the other side.

// Definitions
class Dog extends Mozel {
    @property(String, {required})
    name!:string;
}
class Person extends Mozel {
    @property(String, {required})
    name!:string;
    
    @collection(Dog)
    dogs!:Collection<Dog>;
}

// Instances

let person = Person.create({
   name: 'James',
   dogs: [{name: 'Bobby'}, {name: 'Baxter'}]
});
let exported = person.$export();
let imported = Person.create(exported);

console.log(person.name, imported.name); // both 'James'
console.log(person.dogs.get(1).name); // both 'Baxter'

Change watching

Throughout the hierarchy of the Mozel, you can watch for changes. Watchers can be defined at any level, but if watchers need to persist even if some part of the hierarchy is replaced, they should be defined above the changing level in the hierarchy.

// Definitions
class Toy extends Mozel {
    @property(String, {required, default: 'new'})
    state!:string;
}
class Dog extends Mozel {
    @property(Toy)
    toy?:Toy;
}
class Person extends Mozel {
    @property(Dog)
    dog?:Dog
}

// Instances

let james = Person.create<Person>({
    dog: {
        toy: {}
    }
});

// Watchers

james.$watch('dog.toy.state', (change) => { /*...*/ }); // watcher A
james.$watch('dog.toy', (change) => { /*...*/ }); // watcher B
james.$watch('dog', (change) => { /*...*/ }); // watcher C
james.$watch('dog', (change) => { /*...*/ }, {deep: true}); // watcher D

james.dog = Dog.create(); // potentially triggers watchers A, B, C and D
james.dog.toy = Toy.create(); // potentially triggers watchers A, B and D
james.dog.toy.state = 'old'; // potentially triggers watchers A and D

Note: watchers only get triggered if the new value is different than the old value. If a new dog has a toy with the same state, the dog.toy.state watcher will not be triggered.

Using schema

Using schema in a watcher can provide Typescript type checking:

james.$watch(schema(Person).dog.toy, change => {
    // schema provides type for handler; no need for type casting in handler
})

Wildcard watchers

Wildcards (*) can be used in the watcher's path to match any property. This can also be used to watch for changes in any of a Collection's items:

class Toy extends Mozel {
    @property(String, {required})
    name!:string;
}
class Dog extends Mozel {
    @collection(Toy)
    toys!:Collection<Toy>;
}
let dog = Dog.create({
   toys: [{name: 'ball'}, {name: 'stick'}] 
});
dog.$watch('toys.*.name', ({changePath}) => {
    // do something if the name of any toy changes
    // changePath will provide the path to changed name (* replaced with the index of the toy in the Collection)
});

Watcher properties

A watcher can be configured with the following properties:

  • path: (string, required): The path from the current Mozel to watch.
  • handler: (function, required): The handler to call when a value changes. Takes one argument, which is an object with the following properties:
    • newValue: (any): The value after the change.
    • oldValue: (any): The value before the change.
    • valuePath: (string): The path at the level the watcher is watching.
    • changePath: (string): The path at the deepest level where the actual change occurred.
  • deep: (boolean) If set to true, will respond to changes deeper than the given path. Will make a deep clone to provide the old value.
  • immediate: (boolean) If set to true, will call the handler immediately with the current value.
  • validator: (boolean) If set to true, the handler will be called to validate each new input. If the handler does not return true, the property change will not be applied.

Non-strict mode

Mozels can be set to non-strict mode, in which they will accept invalid property values and will report errors where the values are invalid. Note that, in that case, the mozel will not be considered type-safe, even though Typescript cannot report the type errors. Runtime type checking should always be performed if non-strict mozel properties are used.

let james = Person.create({
    name: 'James',
    dog: { name: 'Bobby' }
});
james.$strict = false;
james.dog.name = 123;

console.log(james.dog.$errors.name); // Error: Dog.name expects string.
console.log(james.$errorsDeep()['dog.name']); // Error: Dog.name expects string.

Advanced

ModelFactory

The ModelFactory enhances plain-data import, allowing to 1) instantiate different subtypes of Mozels, 2) make references between sub-Mozels and 3) inject Mozel dependencies.

Mozel subtype instantiation

// Definitions

class Person extends Mozel {
    @collection(Dog)
	dogs!:Collection<Dog>;
}
class Dog extends Mozel {}
class Pug extends Dog {}
class StBernard extends Dog {}

// Instances

let factory = new MozelFactory();
factory.register([Dog, Pug, StBernard]);
let james = factory.create(Person, {
    dogs: [{_type: 'Pug'}, {_type: 'StBernard'}]
});

console.log(james.dogs.get(0) instanceof Pug); // true
console.log(james.dogs.get(1) instanceof StBernard); // true

References between sub-Mozels

All mozels have a built-in gid property. This property allows the MozelFactory to uniquely identify Mozels, and make references rather than nested Mozels.

// Definitions

class Person extends Mozel {
    @collection(Person, {reference})
    likes!:Collection<Person>;
}

// Instances

let data = [
    {gid: 'james', likes: [{gid: 'peter'}, {gid: 'frank'}]},
    {gid: 'lisa', likes: [{gid: 'james'}, {gid: 'frank'}]},
    {gid: 'jessica', likes: [{gid: 'jessica'}, {gid: 'james'}, {gid: 'frank'}]},
    {gid: 'frank', likes: [{gid: 'lisa'}]}
]

let factory = new MozelFactory();
factory.register(Person);
let people = factory.createSet(data);

console.log(people[0].likes.get(1) === people[3]); // true (both frank)
console.log(people[0].likes.get(1) === people[1].likes.get(1)); // true (both frank)

Mozel dependency injection

let rome = new MozelFactory();
let egypt = new MozelFactory();

// Definitions

class Roman extends Mozel {
    static get type() {
        return 'Person';
    }
}
rome.register(Roman);

class Egyptian extends Mozel {
    static get type() {
        return 'Person'
    }
}
egypt.register(Egyptian);

// Instances

let data = {_type: 'Person'};
let roman = rome.create(data);
let egyptian = egypt.create(data);

console.log(roman instanceof Roman); // true
console.log(egyptian instanceof Egyptian); // true

Templating

Mozel properties that are strings can contain simple template placeholders, denoted by curly brackets. These placeholders can be filled in with mozel.$renderTemplates(data), where data is an object containing the relevant keys. Example:

class Person extends Mozel {
    @property(String, {required})
    name!:string;

    @property(Person)
    child?:Person
}

let james = Person.create<Person>({
    name: 'James {lastName}',
    child: {
        name: 'Fred {lastName}'
    }
})

james.$renderTemplates({lastName: 'Smith'})

console.log(james.name); // James Smith
console.log(james.child.name); // Fred Smith

Logging

Mozel has fine-grained logging controls, based on the Log Control library. For example, it is possible to change the log levels for Mozel, or use a custom driver rather than console:

Mozel.log.setLevel(LogLevel.OFF);
Mozel.log.setDriver({
    trace(...args:any[]){ /* ... */ },
    debug(...args:any[]){ /* ... */ },
    log(...args:any[]){ /* ... */ },
    info(...args:any[]){ /* ... */ },
    warn(...args:any[]){ /* ... */ },
    error(...args:any[]){ /* ... */ }
});