Model is a class with attributes which:
- is deeply observable;
- is automatically serializable to JSON;
- can be mapped to REST endpoint using the declarative annotations;
- is protected from improper updates, checking types at run-time;
- is very fast, easily handling large collections of deeply nested objects (10K and more).
Models are statically typed when used with TypeScript, and much safer than standard classes when used with JavaScript. When used to describe REST endpoints, it augment TypeScript with a dynamic type checks guarding the client-server protocol against errors on both ends.
Model declarations looks close to the shape of JSON objects they describe.
// Create Role model's constructor.
const Role = attributes({
createdAt : Date, // date, represented as UTC string in JSON.
name : String // A string. Guaranteed.
});
// Create Role model's constructor.
const User = attributes({
name : String,
isActive : Boolean, // always boolean, no matter what is assigned.
// Nested collection of roles, represented as an array of objects in JSON.
roles : [Role],
// Nested model, represented as nested object in JSON.
permissions : {
canDoA : Boolean,
canDoB : Boolean,
}
});
const user = new User();
user.onChanges( () => console.log( 'change' ));
user.permissions.canDoA = true;Models may have I/O endpoints attached. There are several endpoints awailable in @type-r/endpoints package, including the standard REST endpoint.
// Use the class form of the model definition.
const User = attributes({
[metadata] : {
// Bind the REST endpoint to enable I/O API.
endpoint : restfulIO( '/api/users' )
}
name : String
}
// Fetch the users list.
const users = await Collection.of( User ).create().fetch();
const firstUser = users.first();
firstUser.isActive = true;
await firstUser.save();Model observe changes in its attributes, including the changes in nested models and collections. Listeners can subscribe and unsubscribe for change events, which makes it easy to integrate models with virtually any view layer.
// Subscribe for the changes...
users.onChanges( () => console.log( 'changes!' ) );
users.first().name = "another name";
// changes!All aspects of model behavior can be controlled on the attribute level through the attribute metadata. It makes it easy to define reusable attribute types with custom serialization, validation, and reactions on changes.
// Email attribute is a string...
const Email = type( String )
// ...having @ symbol in it.
.check( x => !x || x.indexOf( '@' ) >= 0, 'Must be an email' );
const User = attributes({
email : Email.required // Has not be empty to be valid.
// Date which is `null` by default, read it from JSON, but don't save it back.
createdAt : type( Date ).null.dontSave,
//...
})There are four sorts of model attributes:
- Primitive types (Number, String, Boolen) mapped to JSON directly.
- Immutable types (Date, Array, Object, or custom immutable class).
- Nested models and collections represented as nested objects and arrays of objects in JSON.
- Referenced models and collections. References can be:
- serializable to JSON as an ids of the referenced models, used to model one-to-many and many-to-many relashinships in JSON;
- observable, which is a non-persistent run-time only reference, used to model temporary application state.
Create the Model class constructor from the attribute definitions.
Class decorator which must preceede the Model subclass declaration. @define is a mixin which will read attribute definitions from the Model's static attributes and generate class properties accessors accordingly.
@define assembles attribute's update pipeline for each attribute individually depending on its type and employs a number of JIT-friendly optimizations. As a result, Type-R models handle updates about 10 times faster than frameworks like BackboneJS in all popular browsers making a collections of 10K objects a practical choice on a frontend.
Attributes must be defined in static attributes. In a majority of cases, an attribute definition is a constructor function or the default value.
If the function is used as attribute definition, it's assumed to be am attributes constructor and designates attribute type. If it's not a function, it's treated as an attribute's default value and type is being determened from this value type. The following attribute definitions are equivalent:
@define class User extends Model {
static attributes = {
name : String, // Same as ''
email : '' // Same as String
}
}To assign null as a default value both attribute type and value need to be specified, as the type cannot be inferred from null. It's done through the attribute's metadata like this:
nullStringAttr : type( String ).value( null )
A model's unique identifier is stored under the pre-defined id attribute.
If you're directly communicating with a backend (CouchDB, MongoDB) that uses a different unique key, you may set a Model's idAttribute to transparently map from that key to id.
Model's id property will still be linked to Model's id, no matter which value idAttribute has.
@define class Meal extends Model {
static idAttribute = "_id";
static attributes = {
_id : Number,
name : ''
}
}
const cake = new Meal({ _id: 1, name: "Cake" });
alert("Cake id: " + cake.id);Enable model's I/O API by specifying an I/O endpoint. There are the list of endpoints in @type-r/enpoints package to work with browsers local storage, REST, and mock data.
Primitive attribute types are directly mapped to their values in JSON. Assigned value is being converted to the declared attribute type at run time. I.e. if an email is declared to be a string, it's guaranteed that it will always remain a string.
@define class User extends Model {
static attributes = {
name : '',
email : String, // ''
isActive : Boolean, // false
failedLoginCount : Number // 0
}
}JS number primitive type. Assigned value (except null) is automatically converted to number with a constructor call Number( value ).
If something other than null, number, or a proper string representation of number is being assigned, the result of the convertion is NaN and the warning
will be displayed in the console. Models with NaN in their Number attributes will fail the validation check.
JS boolean primitive type. Assigned value (except null) is automatically converted to true or false with a constructor call Boolean( value ).
This attribute type is always valid.
JS string primitive type. Assigned value (except null) is automatically converted to string with a constructor call String( value ).
This attribute type is always valid.
JS Date type represented as ISO UTC date string in JSON. If assigned value is not a Date or null, it is automatically converted to Date with a constructor call new Date( value ).
If something other than the integer timestamp or the proper string representation of date is being assigned, the result of the convertion is Invalid Date and the warning
will be displayed in the console. Models with Invalid Date in their Date attributes will fail the validation check.
Note that the changes to a Date attribute are not observable; dates are treated as immutables and need to be replaced for the model to notice the change.
@define class User extends Model {
static attributes = {
name : '',
email : String, // ''
createdAt : Date
}
}Immutable JS Array mapped to JSON as is. Type-R assumes that an Array attribute contains a raw JSON with no complex data types in it.
Array type is primarily used to represent a list of primitives. It's recommended to use aggregated collections of models for the array of objects in JSON.
If an assigned value is not an Array, the assignment will be ignored and a warning will be displayed in the console.
Array attributes are always valid.
Note that the changes to an Array attribute are not observable; arrays need to be replaced with an updated copy for the model to notice the change. Type-R uses Linked class proxying popular array methods from @linked/value package to simplify manipulations with immutable arrays.
@define class User extends Model {
static attributes = {
name : String,
roles : [ 'admin' ]
}
}
user.$.roles.push( 'user' );Immutable JS Object mapped to JSON as is. Type-R assumes that an Object attribute contains a raw JSON with no complex data types in it.
Plain JSON object type primarily used to represent dynamic hashmaps of primitives. It's recommended to use aggregated models for the complex nested objects in JSON.
If an assigned value is not a plain object, the assignment will be ignored and the warning will be displayed in the console. Object attributes are always valid.
Changes in Object attribute are not observable, object needs to be copied for the Type-R to notice the change. Type-R uses Linked class from @linked/value package to simplify manipulations with immutable objects.
@define class User extends Model {
static attributes = {
name : String,
roles : { admin : true }
}
}
user.$.roles.at( 'user' ).set( false );Function as an attribute value. Please note that functions are not serializable.
Function attributes are initialized with an empty function by default. If something other than function will be assigned, it will be ignored with a error in a console.
If the class constructor used as an attribute type and it's not a model or collection subclass, it is considered to be an immutable attribute. Type-R has following assumptions on immutable attributes class:
- It has
toJSON() - Its constructor can take JSON as a single argument.
Changes in immutable attributes are not observable, object needs to be replaced with its updated copy for the model to notice the change. The class itself doesn't need to be immutable, though, as Type-R makes no other assumptions.
Nested models are the part of the model represented in JSON as nested objects. Nested models will be copied, destroyed, and validated as a part of the parent.
Model has an exclusive ownership rights on its nested members. Nested model can't be assigned to another model's attribute unless the source attribute is cleared or the target attribute has a reference type.
Nested models are deeply observable. A change of the nested model's attributute will trigger the change event on its parent.
Nested model. Describes an attribute represented in JSON as an object.
- Attribute is serializable as
{ attr1 : value1, attr2 : value2, ... } - Changes of enclosed model's attributes will not trigger change of the model.
static attributes = {
users : Collection.of( User ),
selectedUser : memberOf( 'users' )
}Collection containing models. The most popular collection type describing JSON array of objects.
- Collection is serializable as
[ { ...user1 }, { ...user2 }, ... ] - All changes to enclosed model's attributes are treated as a change of the collection.
static attributes = {
users : Collection.of( User )
}Model attribute with reference to existing models or collections. Referenced objects will not be copied, destroyed, or validated as a part of the model.
References can be either deeply observable or serializable.
Serializable id-references is a Type-R way to describe many-to-one and many-to-many relashionship in JSON. Models must have an id to have serializable references. Serializable id-references are not observable.
Id references represented as model ids in JSON and appears as regular models at run time. Ids are being resolved to actual model instances with lookup in the base collection on first attribute access, which allows the definition of a complex serializable object graphs consisting of multiple collections of cross-referenced models fetched asynchronously.
baseCollection argument could be:
- a direct reference to the singleton collection object
- a function returning the collection which is called in a context of the model
- a symbolic path, which is a string with a dot-separated path resolved relative to the model's
this.
Model attribute holding serializable id-reference to a model from the base collection. Used to describe one-to-may relashioship with a model attribute represented in JSON as a model id.
- Attribute is serializable as
model.id - Changes of enclosed model's attributes will not trigger the change of the attribute.
Attribute can be assigned with either the model from the base collection or the model id. If there are no such a model in the base collection on the moment of first attribute access, the attribute value will be null.
static attributes = {
// Nested collection of users.
users : Collection.of( User ),
// Model from `users` serializable as `user.id`
selectedUser : memberOf( 'users' )
}Collection of id-references to models from base collection. Used to describe many-to-many relationship with a collection of models represented in JSON as an array of model ids. The subset collection itself will be be copied, destroyed, and validated as a part of the owner model, but not the models in it.
- Collection is serializable as
[ user1.id, user2.id, ... ]. - Changes of enclosed model's attributes will not trigger change of the collection.
If some models are missing in the base collection on the moment of first attribute access, such a models will be removed from a subset collection.
static attributes = {
// Nested collection of users.
users : Collection.of( User ),
// Collection with a subset of `users` serializable as an array of `user.id`
selectedUsers : Collection.subsetOf( 'users' ) // 'users' == function(){ return this.users }
}Non-serializable run time reference to models or collections. Used to describe a temporary observable application state.
Model attribute holding a reference to a model or collection.
- Attribute is not serializable.
- Changes of enclosed model's attributes will trigger change of the model.
static attributes = {
users : refTo( Collection.of( User ) ),
selectedUser : refTo( User )
}Collection of references to models. The collection itself will be be copied, destroyed, and validated as a part of the model, but not the models in it.
- Collection is not serializable.
- Changes of enclosed model's attributes will trigger change of the collection.
static attributes = {
users : Collection.of( User ),
selectedUsers : Collection.ofRefsTo( User )
}Convert attribute type to a metatype, which is a combination of type and metadata. Attribute's default value is the common example of the a metadata. Metadata can control all aspects of attribute behavior and. It's is added through a chain of calls after the type( Type ) call.
Following values can be used as a Type:
- Constructor functions.
- Plain object, meaning the nested model with a given attributes.
- Array with either constructor or plain object inside, meaning the collection of models.
import { define, type, Model }
const Role = attributes({
name : String
})
const User = attributes({
name : type( String ).value( 'change me' ),
createdAt : type( Date ).dontSave
permissions : type({
canDoA : true,
canDoB : true
}),
roles : type( [Role] ) // type( Collection.of( Role ) )
flags : type( [{ // type( Collection.of( attributes({ name : String }) ) )
name : String
}])
});Since the attribute definition is a regular JavaScript, attribute metatype definition can be shared and reused across the different models and projects.
import { define, type, Model }
const AString = type( String ).value( "a" );
const Dummy = attributes({
a : AString,
b : type( String ).value( "b" )
});Declare an attribute with type Constructor having the custom defaultValue. Normally, all attributes are initialized with a default constructor call.
@define class Person extends Model {
static attributes = {
phone : type( String ).value( null ) // String attribute which is null by default.
...
}
}Shorthand for type(Constructor).value( null ).
@define class Person extends Model {
static attributes = {
phone : type( String ).null // String attribute which is null by default.
...
}
}Similar to type( T ).value( x ), but infers the type from the default value. So, for instance, type( String ) is equivalent to value("").
import { define, type, Model }
@define class Dummy extends Model {
static attributes = {
a : value( "a" )
}
}Attribute validation check.
predicate : value => booleanis the function taking attribute's value and returningtruewhenever the value is valid.- optional
errorMsgis the error message which will be passed in case if the validation fail.
If errorMsg is omitted, error message will be taken from predicate.error. It makes possible to define reusable validation functions.
function isAge( years ){
return years >= 0 && years < 200;
}
isAge.error = "Age must be between 0 and 200";Attribute may have any number of checks attached which are being executed in a sequence. Validation stops when first check in sequence fails. It can be used to define reusable attribute types as demonstrated below:
// Define new attribute metatypes encapsulating validation checks.
const Age = type( Number )
.check( x => x == null || x >= 0, 'I guess you are a bit older' )
.check( x => x == null || x < 200, 'No way man can be that old' );
const Word = type( String ).check( x => indexOf( ' ' ) < 0, 'No spaces allowed' );
@define class Person extends Model {
static attributes = {
firstName : Word,
lastName : Word,
age : Age
}
}Attribute validator checking that the attribute is not empty. Attribute value must be truthy to pass, "Required" string is used as validation error.
required validator always checked first, no matter in which order validators were attached.
Transform the value right before it will be read. The hook is executed in the context of the model.
Transform the value right before it will be assigned. The hook is executed in the context of the model.
If set hook will return undefined, the attribute won't be assigned.
Do not serialize the attribute.
Override the way how the attribute transforms to JSON. Attribute is not serialized when the function return undefined.
When restoring attribute from JSON, transform the value before it will be assigned.
// Define custom boolean attribute type which is serialized as 0 or 1.
const MyWeirdBool = type( Boolean )
.parse( x => x === 1 )
.toJSON( x => x ? 1 : 0 );Attach custom reaction on attribute change. watcher can either be the model's method name or the function ( newValue, attr ) => void. Watcher is executed in the context of the model.
@define class User extends Model {
static attributes = {
name : type( String ).watcher( 'onNameChange' ),
isAdmin : Boolean,
}
onNameChange(){
// Cruel. But we need it for the purpose of the example.
this.isAdmin = this.name.indexOf( 'Admin' ) >= 0;
}
}Turn off observable changes for the attribute.
Model automatically listens to change events of all nested models and collections triggering appropriate change events for its attributes. This declaration turns it off for the specific attribute.
Automatically manage custom event subscription for the attribute. handler is either the method name or the handler function. Type needs to be a Messenger subclass from @type-r/mixture or include it as a mixin.
Both Model and Collection includes Messenger as a mixin.
Override or define an I/O endpoint for the specific model's attribute.