Models
- Defining basic models with attributes and relations
- Extending your models with custom properties
- Registering hooks on models
Model factory
makeModel
is the default model factory function. It defines a new model using
2 arguments and returns an ES6 class:
- The string
type
or a configuration object. - The optional
definition
of the model: an object map containing attributes/relations definitions and custom properties and methods.
The attributes and relations definition represents the schema
of the model.
import { makeModel, attr, hasMany, toDate } from 'foscia/core';
makeModel('posts', {
/* The model definition */
title: attr<string>(),
description: attr<string>(),
publishedAt: attr<Date | undefined>(toDate()),
comments: hasMany<Comment>(),
get published() {
return !!this.publishedAt;
},
});
Extending a model class
makeModel
will return a model class which can be extended by an ES6 class.
export default class Post extends makeModel('posts') {}
The returned model class also provides static methods to extend the definition
already provided to makeModel
.
/* Initial model creation without definition */
makeModel('posts')
.extends({
title: attr<string>(),
description: attr<string>(),
})
.extends({
publishedAt: attr<Date | undefined>(toDate()),
get published() {
return !!this.publishedAt;
},
});
This can be useful when sharing common features across models: creation timestamps, client side ID generation, etc.
If you wish to learn more about the composition capabilities of models, you should read the advanced guide about models composition.
Note on exported value
In many Foscia guides and examples, you will see that the ES6 class
returned by makeModel
is extended before exporting:
we use export default class Post extends makeModel...
instead of
export default makeModel...
.
This has two benefits:
- When using TypeScript, it allows to only import the type of the class using
import type Post from './models/post'
and avoids circular dependencies when models have circular relationships - It gives you more flexibility as you can quickly add custom properties and methods in the future
However, both syntax are valid. Use the one you prefer! 🦄
Using models classes
Model classes can be used like any ES6 class. It can be instantiated, manipulated, etc. Properties will be defined on each instance from the model definition.
const post = new Post();
post.title = 'Hello World!';
post.publishedAt = new Date();
console.log(post.title); // "Hello World!"
console.log(post.published); // true
console.log(post.exists); // false
Please note that most model's interaction (fetching, updating, etc.) are done through actions, you can read the actions guide to learn more about those.
Foscia proposes you multiple utilities functions to interact with models
Read the models' utilities API guideDefinition
You must not use id
, lid
, type
or exists
as key of any
attributes/relations/properties as those keys are internally used by Foscia
or may be used by dependencies (e.g. JSON:API adapter).
IDs
id
is an ID definition factory function used to define your
model's IDs property. It takes 1 argument.
Foscia consider your IDs as string
, number
or null
values by default.
Each model have id
and lid
properties representing record identification.
If you want to change the typing of those properties or transform values, you
can use the id
function.
- TypeScript
- JavaScript
import { id, toString } from 'foscia/core';
id<string>(); // Without config.
id(toString()); // With a transformer.
import { id, toString } from 'foscia/core';
id(); // Without config.
id(toString()); // With a transformer.
Transform
IDs transform works the same as attributes transform.
id
can only be used on id
and lid
keyed model's properties.
There is currently no way of aliasing an ID in Foscia.
Please fill an issue
if this is something you need.
Attributes
attr
is an attribute definition factory function used to define your
model's attributes. It takes 0 to 2 arguments, depending on what
you want to do.
Foscia consider your attributes as non-nullable values by default.
When one of your model contains nullable attributes, you can pass a type
to the factory (e.g. attr<string | null>()
) or set a default attribute value
(e.g. attr({ default: '' })
).
- TypeScript
- JavaScript
import { attr, toDate } from 'foscia/core';
attr<string>(); // Without config.
attr(toDate()); // With a transformer.
attr({ default: null, transformer: toDate() }); // With config.
attr(toDate(), { sync: 'retrieve' }); // With a transformer and config.
attr({ default: () => [] }); // With a factory default (required for objects props).
import { attr, toDate } from 'foscia/core';
attr(); // Without config.
attr(toDate()); // With a transformer.
attr({ default: null, transformer: toDate() }); // With config.
attr(toDate(), { sync: 'retrieve' }); // With a transformer and config.
attr({ default: () => [] }); // With a factory default (required for objects props).
Configuration
You can customize your attribute with the following options:
Key | Type | Default | Description |
---|---|---|---|
transformer | Transform<T> | undefined | The transformer for the property's value when interacting with your backend. |
default | T | undefined | The default value for the property when initializing a model instance. |
alias | string | undefined | Alias of your backend key used for (de)serializing. |
sync | boolean |'retrieve' |'write' | true | Avoid retrieving/writing the property's value from/to the backend. |
Transform
You can use a transformer
to convert an attribute value when (de)serializing
from/to your data source. There are two types or transformer within Foscia:
FunctionTransform
: a function called to transform the value when serializing or deserializing.ObjectTransform
: an object with two methods:serialize
anddeserialize
.
Foscia propose you 4 transformers out of the box:
toDate
,
toNumber
,
toBoolean
and
toString
.
You may need other transformers in your implementation, for example when you are using a library to manage dates (momentjs, dayjs, etc.). You may read the advanced guide on transformers to learn more about those.
Relations
hasMany
and hasOne
are relation definition factory function used
to define your model's relations. As suggested by their names, hasMany
represents a relation to a list of models and hasOne
represents a relation to
a single model. Those take 0 to 1 argument, depending on what you want to do.
Foscia consider your relations as non-nullable values by default.
When one of your model contains nullable relations, you can pass a type
to the factory (e.g. hasOne<User | null>()
) or set a default relation value
(e.g. hasOne({ default: null as User | null })
).
Also consider that non-loaded relations will have a value of undefined
.
- TypeScript
- JavaScript
import { hasOne, hasMany } from 'foscia/core';
import type User from './user';
import type Comment from './comment';
hasOne<User>(); // Without config.
hasOne<User>({ sync: 'retrieve' }); // With config.
hasOne<User>('users'); // With explicit type.
hasOne<User>({ type: 'users', sync: 'retrieve' }); // With explicit type.
hasMany<Comment>(); // Without config.
hasMany<Comment>({ sync: 'retrieve' }); // With config.
hasMany<Comment>('comments'); // With explicit type.
hasMany<Comment>({ type: 'comments', sync: 'retrieve' }); // With explicit type.
import { hasOne, hasMany } from 'foscia/core';
hasOne(); // Without config.
hasOne({ sync: 'retrieve' }); // With config.
hasOne('users'); // With explicit type.
hasOne({ type: 'users', sync: 'retrieve' }); // With explicit type.
hasMany(); // Without config.
hasMany({ sync: 'retrieve' }); // With config.
hasMany('comments'); // With explicit type.
hasMany({ type: 'comments', sync: 'retrieve' }); // With explicit type.
When using TypeScript, you should define the type of the relation to get a
type safe model. We suggest you to use import type
to avoid creating
circular dependencies when having circular model relations.
Configuration
You can customize your relation with the following options:
Key | Type | Default | Description |
---|---|---|---|
default | T | undefined | The default value for the property when initializing a model instance. |
alias | string | undefined | Alias of your backend key used for (de)serializing. |
path | string | undefined | Path used to query the relation. Defaults to the relation's key. |
type | string | undefined | The explicit type of related model. Might be used by the deserializer to know which model to instantiate. |
sync | boolean |'retrieve' |'write' | true | Avoid retrieving/writing the property's value from/to the backend. |
Readonly properties
You can make any ID, attribute or relation readonly using the readOnly
modifier function. readOnly
will work on id
, attr
, hasOne
and hasMany
factories.
Making a property readonly will throw an error when trying to affect a value to it, and emit a TypeScript error on compile (if using TypeScript).
import { attr, makeModel, readOnly, toDate } from 'foscia/core';
export default class User extends makeModel('users', {
verifiedAt: readOnly(attr(toDate())),
}) {}
const user = new User();
// TS error on compile and error on runtime:
user.verifiedAt = new Date();
Custom properties
In addition to IDs, attributes and relations, you can implement additional properties to your model. It's useful when you need computed values (getters) or specific instance methods.
This can be done using the definition or an extending class:
// Directly in the definition.
export default makeModel('users', {
firstName: attr(),
lastName: attr(),
get fullName() {
return `${this.firstName} ${this.lastName}`
}
});
// Inside an extending class.
export default class User extends makeModel('users', {
firstName: attr(),
lastName: attr(),
}) {
get fullName() {
return `${this.firstName} ${this.lastName}`
}
}
Hooks
You can hook multiple events from your model instances using the hook registration functions:
onRetrieved
: instance was deserialized from a backend response.onCreating
: action to create instance will run soon.onCreated
: action to create instance was ran successfully.onUpdating
: action to update instance will run soon.onUpdated
: action to update instance was ran successfully.onSaving
: action to save (create or update) instance will run soon (always ran afteronCreating
andonUpdating
).onSaved
: action to save (create or update) instance was ran successfully (always ran afteronCreated
andonUpdated
).onDestroying
: action to destroy instance will run soon.onDestroyed
: action to destroy instance was ran successfully.
To register a hook callback, you must pass a model class and a callback function to the registration function. It will return a function that you can call to unregister the hook. All model hooks' callbacks have the concerned model instance as the only provided argument.
import { onSaving } from 'foscia/core';
// After this, the hook will run on each User instance saving.
const unregisterThisHook = onSaving(User, async (user) => {
// TODO Do something (a)sync with user instance before saving.
});
// After this, this hook will never run again.
unregisterThisHook();
Hooks' callbacks are async and executed in a sequential fashion (one by one, not parallelized).
You can temporally disable hook execution for a given model by using the
withoutHooks
function.
Be aware that withoutHooks
will always return a promise, even when
your callback is a sync function.
import { withoutHooks } from 'foscia/core';
const asyncResultOfYourCallback = await withoutHooks(User, async () => {
// TODO Do something async and return it.
});