Skip to main content

Models

What you'll learn
  • 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.

npx 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 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.

info

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
info

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 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.
  • id properties can be transformed. Read more on the transformers guide.
  • id pending definition supports chained modifiers.
warning

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.

Please fill an issue if this is something you need.

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

NameParametersEffect
transformObjectTransformerUse a transformer.
defaultunknown | (() => unknown)Set a default value on new instance.
readOnlybooleanSet 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.

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

NameParametersEffect
transformObjectTransformerUse a transformer.
defaultunknown | (() => unknown)Set a default value on new instance.
readOnlybooleanSet read-only state.
nullable-Set current type as nullable.
aliasstringSet an alias for data source interactions.
syncboolean | '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.

Example

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');

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']);
warning

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

NameParametersEffect
configstring | string[] | ModelRelationConfigSpecify the relation configuration: related types and custom implementations related options, see relations configuration.
defaultunknown | (() => unknown)Set a default value on new instance.
readOnlybooleanSet read-only state.
nullable-Set current type as nullable.
aliasstringSet an alias for data source interactions.
syncboolean | '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.

NameParametersEffect
typestring | string[]Specify related models' types.
pathstringonly: 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}`;
}
}
warning

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.
});
warning

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

info

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 calling new 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 after onCreating and onUpdating).
  • onSaved: action to save (create or update) instance was ran successfully (always ran after onCreated and onUpdated).
  • 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

info

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

info

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:

KeyTypeDescription
$existsbooleanTells if the instance exists in the data source.
$loadedDictionary<true>Dictionary containing true for each loaded relation.
$valuesPartial<ModelValues>Current values of the instance.
$originalModelSnapshotOriginal snapshot since last sync.
$rawanyData source value which was deserialized to create inside the instance.
$modelModelClassBase model class.

Models

Each model have the following special properties:

KeyTypeDescription
$typestringUnique model type.
$configModelConfigConfiguration options.
$schemaModelSchemaSchema containing IDs/attributes/relations definition.
$composablesModelComposable[]Array of used composables.
$bootedbooleanTells if model is booted (constructed at least once).