Attributes

Models have been given powerful tools to manage data without involved logic and decision trees. There are 5 main parts to be considered when using a model:

Casting

Casting transforms values when accessing or setting attributes on a model. To define the casters on your model you should define a getter for the casts property.

// User.js
import { Model } from '@upfrontjs/framework';

export default class User extends Model {
    get casts() {
        return {
            isAdmin: 'boolean'
        }
    }
}
// User.ts
import { Model } from '@upfrontjs/framework';
import type { CastType } from '@upfrontjs/framework';

export default class User extends Model {
    // you can set the return value or
    get casts(): Record<string, CastType> {
        return {
            isAdmin: 'boolean' // set this to `as const` to change string type to 'boolean' type
        }
    }
}

The following cast types are available:

WARNING

If the values cannot be cast, an error will be thrown.

'boolean'

Casts the values to boolean. It does not evaluate values as truthy or falsy, it expects the numeric 0, 1, booleans or 'true', 'false' in any casing. This is useful to parse the data from the back-end.

'string'

Casts the values to a string. This casts all values, meaning undefined will become 'undefined' and objects will have their toString method called.

'number'

Casts the values to a number.

'collection'

Casts the values to a Collection by calling the collection constructor.

'datetime'

Cast the values to the given date time by calling the method or its constructor. Default: If no date time defined in the config by default it will construct a new Dateopen in new window object with the value. This when receives a null value it will return null instead of a Date set to unix epoch.

custom object

This is an object which implements the AttributeCaster type. Meaning it has a get and a set method both of which accepts a value, and an Attributes object (the equivalent of getRawAttributes) argument.

// User.js
import { Model } from '@upfrontjs/framework';

export default class User extends Model {
    get casts() {
        return {
            myAttribute: {
                get (value, attributes) {
                    return String(value) + '1';
                },
                set (value, attributes) {
                    return String(value) + '2';
                }
            }
        }
    }
}
// User.ts
import { Model } from '@upfrontjs/framework';
import type { AttribteCaster } from '@upfrontjs/framework';

const myAttribute: AttributeCaster = {
    get (value, attributes) {
        return String(value) + '1';
    },
    set (value, attributes) {
        return String(value) + '2';
    }
}

export default class User extends Model {
    get casts() {
        return { myAttribute }
    }
}

Further casting methods

setCasts

The setCasts method sets the casts for the model, replacing the existing configuration.

import User from '@upfrontjs/framework';

const user = new User;

user.hasCast('test'); // false
user.setCasts({ test: 'boolean' }).hasCast('test'); // true

hasCast

The hasCast method determines whether the given key has a cast defined.

import User from '@upfrontjs/framework';

const user = new User;

user.hasCast('test'); // false
user.setCasts({ test: 'boolean' }).hasCast('test'); // true

mergeCasts

The mergeCasts method merges the given casts with the existing casting configuration.

import User from '@upfrontjs/framework';

const user = new User;

user.setCasts({ test: 'boolean' })
    .mergeCasts({ test1: 'number' })
    .hasCast('test'); // true
user.hasCast('test1'); // true

Guarding

With models there are option to mass assign attributes to the model though the constructor or the fill methods. However, when you're constructing your attributes dynamically, you may come across scenarios where you may not want every attribute assigned to the model on mass assignment. For these, there is an option to white and black list attribute keys.

To define these rules, you create a fillable and/or guarded getter function which returns an array of strings.

// User.js
export default class User extends Model {
    get fillable() {
        return ['id', 'name'];
    }

    get guarded() {
        return ['*'];
    }
}

This in action will look like:

import User from '@Models/User';

const user = User.make({ someAttribute: 1, name: 'name' });
user.getAttributes(); // { name: 'name' }

There is an option where these return values include '*'. In this case all attributes will respect the guarding defined.

The default settings are guarded: ['*'] and fillable: [].

WARNING

If key is defined in both guarded and fillable, then fillable takes priority when evaluating whether an attribute is guarded or not.

To manage the fillable settings there are a couple utility methods available:

getFillable

The getFillable method returns the currently fillable attributes.

import User from '@Models/User';

const user = new User;
user.getFillable(); // ['id', 'name']

getGuarded

The getFillable method returns the currently guarded attributes.

import User from '@Models/User';

const user = new User;
user.getGuarded(); // ['*']

mergeFillable

The mergeFillable method merges in the given attributes to the existing configuration.

import User from '@Models/User';

const user = new User;
user.mergeFillable(['dob']).getFillable(); // ['id', 'name', 'dob']

mergeGuarded

The mergeGuarded method merges in the given attributes to the existing configuration.

import User from '@Models/User';

const user = new User;
user.mergeGuarded(['dob']).getGuarded(); // ['*', 'dob']

setFillable

The setFillable method replaces the existing fillable configuration.

import User from '@Models/User';

const user = new User;
user.setFillable(['dob']).getFillable(); // ['dob']

setGuarded

The setGuarded method replaces the existing guarded configuration.

import User from '@Models/User';

const user = new User;
user.setGuarded(['dob']).getGuarded(); // ['dob']

isFillable

The isFillable method determines whether the given attribute key is fillable or not.

import User from '@Models/User';

const user = new User;
user.isFillable('dob'); // false
user.setFillable(['dob']).isFillable('dob'); // false

isGuarded

The isGuarded method determines whether the given attribute key is guarded or not.

import User from '@Models/User';

const user = new User;
user.isGuarded('dob'); // false
user.setGuarded(['dob']).isGuarded('dob'); // false

Mutators/Accessors

Besides casts there is also an alternative method to transform your values on the fly. You can define accessors which are called when you're accessing an attribute and mutators which are called when you're setting a value. You can define them like the following example

// User.js
import { Model } from '@upfrontjs/framework';

export default class User extends Model {
    getFullNameAttribute(name) {
        return this.title + ' ' + name;
    }

    setFullNameAttribute(name) {
        this.attributes.fullName = name.startsWith(this.title + ' ')
            ? name.slice(this.title.length + 1)
            : name;
    }
}

WARNING

The methods always have to follow the following format:

set/get + your attribute name in PascalCase + Attribute()

After you have defined your accessors and mutators they'll be automatically called when accessing the attribute on the model and has been previously mass assigned or has been set with the setAttribute method.

import User from '@Models/User';

const user = User.make({ title: 'Dr.', fullName: 'John Doe' });
user.fullName; // 'Dr. John Doe'

Furthermore, when defining an accessor that has had no value set previously, the value will be made available on the model as a property. This means that you can access the value like so:

// User.js
import { Model } from '@upfrontjs/framework';

export default class User extends Model {
    getFullNameAttribute(name) {
        return name ?? 'no name set';
    }
}

const user = User.make();
user.fullName; // 'no name set'

Attribute management

attributeCasing

While some prefer to name their variables and object keys as camelCase others will prefer snake_case or perhaps there are different conventions between the front and back end. To accommodate such preferences you can set the attributeCasing getter to return either 'camel' or 'snake' like so:

// User.js
import { Model } from '@upfrontjs/framework';

export default class User extends Model {
    get attributeCasing() {
        return 'snake';
    }
}
// User.ts
import { Model } from '@upfrontjs/framework';

export default class User extends Model {
    public get attributeCasing() {
        return 'snake' as const;
    }
}

When using mass-assignment like the create and the fill methods, all keys of the given arguments will automatically and recursively transform to the set casing. e.g.: user.fill({ some_value: 1 }).someValue; // 1 The default value is 'camel'. This can be counteracted by the serverAttributeCasing getter method when sending data to the server.


To aid with managing data on the model, numerous methods are available to use:

for...of

Just like object literals, models are also iterable using a for of loop. This loop will iterate over the attributes, then the relations.

import User from '@Models/User';
import Shift from '@Models/Shift';

const user = User.make({ title: 'Dr.', shifts: [new Shift] });

for (const [item, key] of user) {
    // ...
}

setAttribute

The setAttribute method is what's used for setting attributes on the model. Its role is to delegate the value transformation, set the value in the right context and create access to the value. When an attribute has been set on the model, you'll be able to access the values like so:

import User from '@Models/User';

const user = new User;
user.setAttribute('name', 'John Doe');
user.name; // 'John Doe'
user.name = 'Jane Doe';
user.name; // 'Jane Doe'

setAttribute is used internally when using mass-assignment with the fill or constructor methods, meaning the same behaviour will apply. Furthermore, it can also handle the relationship values just like the addRelation method would.

When setting an attribute following priority will apply to value:

  • If exists use the mutator to set the attribute.
  • If cast defined, use the cast value to set the attribute.
  • If the key argument is a defined relation and the value argument is a valid relation value, set the relation value.
  • Otherwise, just set the attribute on the model.

getAttribute

The getAttribute method is what's used for getting attributes from the model. Its role is to delegate the value transformation and get the attribute from different sources. When an attribute has been set on the model, you'll be able to access the values like so:

import User from '@Models/User';

const user = User.make({ name: 'John Doe' });
user.name; // 'John Doe'
user.getAttribute('name'); // 'John Doe'

Optionally the method takes a second argument which is returned if the key cannot be resolved. This method is internally used when accessing attributes on the model like in the above example. The model will resolve the value in the following order:

  • If the attribute exists and has an accessor, return the accessor's value.
  • If the attribute exists, and a cast has been defined, return the cast value.
  • If the given key is a relation's name, and the relation has been loaded, return the relation.
  • If the key is a property of the model, and it's a function, return the default value.
  • If the key is a property of the model, return its value.
  • Otherwise, return the default value.

getAttributes

The getAttributes method returns all the attributes that has been set in an object format. It resolves the values in the same methodology as the getAttribute method except it only returns attributes.

import User from '@Models/User';

const user = User.make({ firstName: 'John', lastName: 'Doe' });
user.getAttributes(); // { firstName: 'John', lastName: 'Doe' }

getRawAttributes

The getRawAttributes method returns all the attributes similarly to getAttributes except is does not use any value transformation.

getAttributeKeys

The getAttributeKeys method returns all the attribute keys on the model currently set.

import User from '@Models/User';

const user = User.make({ firstName: 'John', lastName: 'Doe' });
user.getAttributeKeys(); // ['firstName', 'lastName']

deleteAttribute

The deleteAttribute method removes the attribute with the given key from the attributes. Further more if the key also represents a relation, remove the relation. Otherwise, if it is a property of the model and isn't a function remove the property.

// User.js
import { Model } from '@upfrontjs/framework';

export default class User extends Model {
    myFunc() {
        return;
    }
}

// myScript.js
import User from '@Models/User';
import Shift from '@Models/Shift';

const user = User.make({ firstName: 'John', lastName: 'Doe' });
user.property = 1;
user.addRelation('shifts', [new Shift]);

user.deleteAttribute('firstName').firstName; // undefined
user.deleteAttribute('property').property; // undefined
user.deleteAttribute('shifts').shifts; // undefined
user.deleteAttribute('myFunc').myFunc; // [Function: myFunc]

fill

The fill method merges in the given attributes onto the model that are considered fillable. Under the hood it uses the setAttribute method, meaning the same logic applies.

import User from '@Models/User';

const user = User.make({ firstName: 'John', lastName: 'Doe' });
user.fill({ fistName: 'Jane', title: 'Dr.' }).getAttributes(); // { firstName: 'Jane', lastName: 'Doe', title: 'Dr. }

forceFill

The forceFill method merges in the given attributes regardless of guarding. Under the hood it uses the setAttribute method, meaning the same logic applies.

only

The only method returns only the attributes that match the given key(s).

import User from '@Models/User';

const user = User.make({ firstName: 'John', lastName: 'Doe' });
user.only('fistName'); // { firstName: 'John' }

except

The except method returns only the attributes that match does not the given key(s).

import User from '@Models/User';

const user = User.make({ firstName: 'John', lastName: 'Doe', title: 'Dr.' });
user.except(['fistName', 'title']); // { lastName: 'Doe' }

toJSON

The toJSON method returns the json representation of the model's attributes and relations.

import User from '@Models/User';

const user = User.make({ name: 'John Doe' });
user.addRelation('shifts', new Shift({ shiftAttr: 1 })).toJSON(); // {"name":"John Doe","shifts":[{"shiftAttr":1}]}

Tracking changes

To determine and manage the data's state on the model, and to examine the difference between the original data, and the data the model has been constructed with, a couple of helper methods have been added to that end.

syncOriginal

The syncOriginal method set's the original state of the data to the current state of it.

import User from '@Models/User';

const user = User.make({ name: 'John Doe' });
user.name = 'Jane Doe';
user.getOriginal('name'); // 'John Doe'
user.syncOriginal().getOriginal('name'); // 'Jane Doe'

reset

The reset method will set the attributes to the original values, discarding any changes.

import User from '@Models/User';

const user = User.make({ name: 'John Doe' });
user.name = 'new name';
user.getChanges(); // { name: 'new name' }
user.reset().getChanges(); // {}

getOriginal

The getOriginal method returns the original value in a resolved format. Meaning it will use the accessor if defined, or it will cast the value if cast defined. The method optionally takes a second argument which the method will default to if the key is not found.

import User from '@Models/User';

const user = User.make({ name: 'John Doe' });
user.name = 'Jane Doe';
user.getOriginal('name'); // 'John Doe'
user.getOriginal('title', 'Mr.'); // 'Mr.'

getRawOriginal

The getRawOriginal method works the same as the getOriginal method, except it will not transform the values.

getChanges

The getChanges method returns only the changed data since the model was constructed based on deep equality. Optionally it takes a key, in which case only the changes for the given key is returned. If there are no changes, an empty object is returned.

import User from '@Models/User';

const user = User.make({ name: 'John Doe', title: 'Mr.' });
user.getChanges(); // {}
user.name = 'Jane Doe';
user.getChanges(); // { name: 'Jane Doe' }
user.getChanges('name'); // { name: 'Jane Doe' }
user.getChanges('title'); // {}

getDeletedAttributes

The getDeletedAttributes method returns only the deleted attributes since the last sync with the original attributes. Optionally it takes a key in which case, only the key will be included in the return value if it has been deleted.

import User from '@Models/User';

const user = User.make({ name: 'John Doe', title: 'Mr.' });
user.getDeletedAttributes(); // {}
user.deleteAttribute('name').getDeletedAttributes(); // { name: 'John Doe' }
user.deleteAttribute('title').getDeletedAttributes('name'); // { name: 'John Doe' }

getNewAttributes

The getNewAttributes method returns only the newly added attributes since the last sync with the original attributes. Optionally it takes a key in which case, only the key will be included in the return value if it has been recently added.

import User from '@Models/User';

const user = User.make({ name: 'John Doe', title: 'Mr.' });
user.getNewAttributes(); // {}
user.setAttribute('attr', 1).getNewAttributes(); // { attr: 1 }
user.setAttribute('attr2', 2).getNewAttributes('attr'); // { attr: 1 }

hasChanges

The hasChanges method determines whether any changes have occurred since constructing the model. Optionally it can take a key argument which only inspect the attribute's state which matches the given key. It takes new and deleted attributes into consideration.

import User from '@Models/User';

const user = User.make({ name: 'John Doe', title: 'Mr.' });
user.hasChanges(); // false
user.name = 'Jane Doe';
user.hasChanges(); // true
user.hasChanges('name'); // true
user.hasChanges('title'); // false
user.deleteAttribute('title').hasChanges('title'); // true
user.setAttribute('attr', 1).hasChanges('attr'); // true

isDirty

The isDirty method is an alias of the hasChanges method.

isClean

The isClean method determines whether the attributes match with the original attributes since the model constructing. Optionally it can take a key argument in which case it only inspect the given attribute's state.

import User from '@Models/User';

const user = User.make({ name: 'John Doe', title: 'Mr.' });
user.isClean(); // true
user.name = 'Jane Doe';
user.isClean(); // false
user.isClean('name'); // false
user.isClean('title'); // true