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 Date 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
keyargument is a defined relation and thevalueargument 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