Models
- Defining basic models with attributes and relations
- Extending your models with custom properties
- Registering hooks on models
Models
Using CLI
You can generate a new model using @foscia/cli
.
- NPM
- YARN
- PNPM
- Bun
npx foscia make model post
yarn foscia make model post
pnpm foscia make model post
bun foscia make model post
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 IDs/attributes/relations definitions, custom properties, custom methods and composables.
The attributes and relations definition represents the schema
of the model.
import { makeModel, attr, hasMany, toDateTime } from '@foscia/core';
export default makeModel('posts', {
/* The model definition */
title: attr<string>(),
description: attr<string>(),
publishedAt: attr(toDateTime()).nullable(),
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 */
export default makeModel('posts')
.extend({
title: attr<string>(),
description: attr<string>(),
})
.extend({
publishedAt: attr(toDateTime()),
get published() {
return !!this.publishedAt;
},
})
.configure({
/* ...configuration */
});
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.
Each call to extend
or configure
will return a child class of the
original model class.
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.
Utilities
Foscia proposes you multiple utilities functions to interact with models.
Read the models' utilities API guide
Definition
IDs
Description
id
is a pending ID definition factory function used to define your model's IDs
properties. You can pass a transformer or a default value to this factory.
- Foscia consider your IDs as
string
,number
ornull
values by default. Each model haveid
andlid
properties representing record identification. If you want to change the typing of those properties or transform values, you can use theid
function. id
properties can be transformed. Read more on the transformers guide.id
pending definition supports chained modifiers.
id
cannot use another type than number
, string
or null
and can only be
used on id
and lid
keyed model's properties. There is currently no way of
aliasing an ID in Foscia.
Example
import { id, toString } from '@foscia/core';
// With TypeScript type.
id<string>();
// With default value.
id(null as string | null);
id(() => null as string | null);
// With transformer to infer type.
id(toString());
// With chained modifiers.
id(toString())
.nullable()
.default(() => null)
.readOnly();
Chained modifiers
Name | Parameters | Effect |
---|---|---|
transform | ObjectTransformer | Use a transformer. |
default | unknown | (() => unknown) | Set a default value on new instance. |
readOnly | boolean | Set read-only state. |
nullable | - | Set current type as nullable. |
Attributes
Description
attr
is an attribute definition factory function used to define your model's
attributes. You can pass a transformer or a default value to this factory.
- Foscia consider your attributes as non-nullable values by default.
- Non-loaded attributes will have a value of
undefined
. attr
properties can be transformed. Read more on the transformers guide.attr
pending definition supports chained modifiers.
Example
import { attr, toDateTime } from '@foscia/core';
// With TypeScript type.
attr<string>();
// With default value.
attr(null as string | null);
attr(() => null as string | null);
// With transformer to infer type.
attr(toDateTime());
// With chained modifiers.
attr(toDateTime())
.nullable()
.default(() => null)
.readOnly()
.alias('published-at')
.sync('pull');
Chained modifiers
Name | Parameters | Effect |
---|---|---|
transform | ObjectTransformer | Use a transformer. |
default | unknown | (() => unknown) | Set a default value on new instance. |
readOnly | boolean | Set read-only state. |
nullable | - | Set current type as nullable. |
alias | string | Set an alias for data source interactions. |
sync | boolean | 'pull' | 'push' | Set sync state: true for always (default), false for never, 'pull' to ignore property when serializing, 'push' to ignore property when deserializing). |
Relations
Description
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. You can pass the relation information to this factory.
- Foscia consider your relations as non-nullable values by default.
- Non-loaded relations will have a value of
undefined
. hasOne
andhasMany
pending definition supports chained modifiers.- Depending on your data structure, you should follow one of the recommandation over relations definition to avoid circular dependencies errors:
Example
- hasOne
- hasMany
import { hasOne } from '@foscia/core';
import User from './user';
// With explicit related model.
hasOne(() => User);
// With TypeScript type.
hasOne<User>();
// With explicit related type.
hasOne<User>('users');
// With config object.
hasOne<User>({ type: 'users', path: 'author' });
// With chained modifiers.
hasOne<User>('users')
.config({ path: 'author' })
.nullable()
.default(() => null)
.readOnly()
.alias('author')
.sync('pull');
import { hasMany } from '@foscia/core';
import Comment from './comment';
// With explicit related model.
hasMany(() => Comment);
// With TypeScript type.
hasMany<Comment[]>();
// With explicit related type.
hasMany<Comment[]>('comments');
// With config object.
hasMany<Comment[]>({ type: 'comments', path: 'comments' });
// With chained modifiers.
hasMany<Comment[]>('comments')
.config({ path: 'comments' })
.nullable()
.default(() => null)
.readOnly()
.alias('comments')
.sync('pull');
Polymorphism
Defining a polymorphic relation is pretty simple:
// With explicit related models.
hasOne(() => [Post, Comment]);
hasMany(() => [Post, Comment]);
// With explicit related types.
hasOne<Post | Comment>(['posts', 'comments']);
hasMany<(Post | Comment)[]>(['posts', 'comments']);
Polymorphism may require a specific configuration of your action or your data source.
For example, polymorphism is supported out of the box with JSON:API, because
each record have a special type
property which is resolved by Foscia to the
correct model.
When implementing polymorphism with a REST data source, your record JSON object
must contain a type
property matching your Foscia models' types.
Chained modifiers
Name | Parameters | Effect |
---|---|---|
config | string | string[] | ModelRelationConfig | Specify the relation configuration: related types and custom implementations related options, see relations configuration. |
default | unknown | (() => unknown) | Set a default value on new instance. |
readOnly | boolean | Set read-only state. |
nullable | - | Set current type as nullable. |
alias | string | Set an alias for data source interactions. |
sync | boolean | 'pull' | 'push' | Set sync state: true for always (default), false for never, 'pull' to ignore property when serializing, 'push' to ignore property when deserializing). |
Recommandations
Explicit model when not having circular references
If your models does not contain ciruclar references (e.g. Post has a "author" relation to User and User has a "favoritePosts" relation to Post), you should define your relations with explicit related model as follow:
import { hasOne } from '@foscia/core';
import User from './user';
hasOne(() => User);
This will make identifying a relation's related model easier for Foscia and make registering your models optional.
Explicit type when having circular references
If your models does contain ciruclar references (e.g. Post has a "author" relation to User and User has a "favoritePosts" relation to Post), you should define your relations with a TypeScript type as follow:
import { hasOne } from '@foscia/core';
import type User from './user';
hasOne<User>();
Notice the import type
for type definition when using TypeScript. This will
import the type of the model and will avoid circular dependencies at runtime.
You should also correctly register your models on your action factory registry.
When using a data source where each record contains an explicit type info (e.g. JSON:API), you don't have anything to do.
If your data source does not provide an explicit type info, you should either:
- Let Foscia guess the related type from the relation's name.
- Add an explicit mapped type to the relation as follow:
import { hasOne } from '@foscia/core';
import type User from './user';
hasOne<User>('users');
Configuration
Using config
chained modifier, you can customize the relation configuration,
which can vary between implementations and used dependencies.
Name | Parameters | Effect |
---|---|---|
type | string | string[] | Specify related models' types. |
path | string | only: HTTP Specify a path alias for dedicated relation endpoints. |
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(toString()),
lastName: attr(toString()),
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
});
// Inside an extending class.
export default class User extends makeModel('users', {
firstName: attr(toString()),
lastName: attr(toString()),
}) {
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
Please note that when using the object spread syntax, you won't be able to get a
correctly typed this
context inside the current definition object (it is
still available in next/previous definition or in class body). This is because
of an
issue due to a TypeScript limitation.
Hooks
Using hooks
You can hook multiple events on model's instances using hook registration
functions, such as onCreating
.
To hook on an event, use the dedicated hook registration function. Each hook registration function will return a callback to unregister the hook.
import { onCreating } from '@foscia/core';
// After this, the hook will run on each User instance saving.
const unregisterThisHook = onCreating(User, async (user) => {
// TODO Do something (a)sync with user instance before saving.
});
// After this, this hook will never run again.
unregisterThisHook();
You can also use unregisterHook
to remove a registered hook from a model.
import { onCreating, unregisterHook } from '@foscia/core';
const myCreatingHook = async (user: User) => {
// TODO Do something (a)sync with user instance before saving.
};
// After this, the hook will run on each User instance saving.
onCreating(User, myCreatingHook);
// After this, this hook will never run again.
unregisterHook(User, 'creating', myCreatingHook);
Finally, you can temporally disable hook execution for a given model by using
the withoutHooks
function. withoutHooks
can receive a sync or async
callback: if an async callback is passed, it will return a Promise
.
import { withoutHooks } from '@foscia/core';
const asyncResultOfYourCallback = await withoutHooks(User, async () => {
// TODO Do something async and return it.
});
Foscia may also register hooks internally when using some features, such as relations inverse, etc. Be careful when running a callback without model's hooks, as those hooks will also be disable.
Instances hooks
Most instances hooks callbacks can be asynchronous and are executed in a
sequential fashion (one by one, not parallelized). Only init
is a sync
hook callback.
You can hook on multiple events on instances:
onInit
: instance was constructed by callingnew
on model class.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.
Each of these hooks callback will receive an instance as parameter:
import { onCreating } from '@foscia/core';
onCreating(User, async (user) => {
});
Models hooks
Models hooks callbacks are synchronous and are executed in a sequential fashion (one by one, not parallelized).
Only boot
event can be hooked on a model class, using onBoot
.
It is like onInit
, but will be called only once per model and will
receive the model class.
import { onBoot } from '@foscia/core';
onBoot(User, async (UserModel) => {
});
Properties hooks
Instances properties hooks callbacks are synchronous and are executed in a sequential fashion (one by one, not parallelized).
You can hook on multiple events on instances' properties:
onPropertyReading
: an instance property getter is called (ran before getting value).onPropertyRead
: an instance property getter is called (ran after getting value).onPropertyWriting
: an instance property setter is called (ran before setting value).onPropertyWrite
: an instance property setter is called (ran after setting value).
Reading hooks will receive the instance and property key, current value and definition:
import { onPropertyReading, onPropertyRead } from '@foscia/core';
// Hook on specific property reading.
onPropertyReading(User, 'email', ({ instance, key, value, def }) => {
});
onPropertyRead(User, 'email', ({ instance, key, value, def }) => {
});
// Hook on any property reading.
onPropertyReading(User, ({ instance, key, value, def }) => {
});
onPropertyRead(User, ({ instance, key, value, def }) => {
});
Writing hooks will receive the instance and property key, previous value, next value and definition:
import { onPropertyWriting, onPropertyWrite } from '@foscia/core';
// Hook on specific property reading.
onPropertyWriting(User, 'email', ({ instance, key, prev, next, def }) => {
});
onPropertyWrite(User, 'email', ({ instance, key, prev, next, def }) => {
});
// Hook on any property reading.
onPropertyWriting(User, ({ instance, key, prev, next, def }) => {
});
onPropertyWrite(User, ({ instance, key, prev, next, def }) => {
});
To unregister a property hook callback using unregisterHook
, you should
pass the event name with or without the property's key,
depending on if it is a specific property hook callback or not:
import { unregisterHook } from '@foscia/core';
// Unregister specific property hook.
unregisterHook(User, 'property:reading:email', registeredCallback);
unregisterHook(User, 'property:read:email', registeredCallback);
unregisterHook(User, 'property:writing:email', registeredCallback);
unregisterHook(User, 'property:write:email', registeredCallback);
// Unregister non-specific properties hook.
unregisterHook(User, 'property:reading', registeredCallback);
unregisterHook(User, 'property:read', registeredCallback);
unregisterHook(User, 'property:writing', registeredCallback);
unregisterHook(User, 'property:write', registeredCallback);
Using hooks with composition
All models, instances and properties hooks can be used on composables and models factories.
Special properties
Instances
Each model's instance have the following special properties:
Key | Type | Description |
---|---|---|
$exists | boolean | Tells if the instance exists in the data source. |
$loaded | Dictionary<true> | Dictionary containing true for each loaded relation. |
$values | Partial<ModelValues> | Current values of the instance. |
$original | ModelSnapshot | Original snapshot since last sync. |
$raw | any | Data source value which was deserialized to create inside the instance. |
$model | ModelClass | Base model class. |
Models
Each model have the following special properties:
Key | Type | Description |
---|---|---|
$type | string | Unique model type. |
$config | ModelConfig | Configuration options. |
$schema | ModelSchema | Schema containing IDs/attributes/relations definition. |
$composables | ModelComposable[] | Array of used composables. |
$booted | boolean | Tells if model is booted (constructed at least once). |