Defining Models
Define your Kysely instance and types
Begin by following the Kysely instructions for setting up your database Types and connection.
You can see an example of database types used by this repository for testing in test/types/database.ts. An example database connection can be found in test/database/db.ts.
This is all just standard Kysely setup up to this point, so follow their instructions for this part!
Define your Vasta Models
You'll need to create new classes for each of your models, extending the defineModel function provided by Vasta. Each model should specify the corresponding table name and define its attributes.
The defineModel function takes a configuration object as a paramter. Here are some minumum properties for the configuration object:
db: This should be set to your Kysely database instance.table: This should be set to the name of the table in your database that this model represents. This should be a string typedas const.primaryKey: (optional) the primary key column for the table, defaults to "id".
import { defineModel } from "vasta-orm";
import db from "@/database/db";
export default class Person extends defineModel({
db, // Your Kysely database instance
table: "people", // the name of the table in your Kysely database type that this model represents
id: "id", // optional, defaults to "id"
}) {}
Hidden Fields
Use the hidden option for an attribute to keep sensitive columns out of serialized model output. When a field is listed in hidden, it is omitted from toJSON() and JSON.stringify(model), but it still exists on model.attributes.
A good example of this is a password field on a User model. You want to be able to access the password when you need to, but you don't want it to be included when you serialize the model to JSON.
import { defineModel } from "vasta-orm";
import db from "@/database/db";
export default class User extends defineModel({
db,
table: "users",
attributes: {
password: {
hidden: true,
},
},
}) {}
Default Attributes
You can also specify default attributes for your models. These attributes will be applied to the model instance if they are not provided in the new model constructor when creating a new instance of a model. Creating a new instance without passing in attributes included in the defaults will not throw a type error since they are provided by the default attributes setting.
export default class Pet extends defineModel({
// ...other config
attributes: {
counter: {
default: 0
},
type: {
// You can also use a function to set dynamic defaults
default: () => "dragon",
}
},
}) {
Accessors and Mutators (get/set)
Model attributes can be modified with get and set functions to change the value which will be retrieved and stored in your database. These functions are defined on the defineModel config for the attribute, and should return the matching type for the attribute being modified
Accessors (get)
class Person extends defineModel({
db,
table: "people",
attributes: {
name: {
get: (value) => value.toUpperCase(),
},
},
}) {}
const person = new Person({ name: "Joe" }); // the name value is not modified
// reading the name attribute runs the get function and returns "JOE"
// even though the actual attribute value is "Joe"
const allCapsPersonName = person.name;
person.save(); // The string "Joe" is written to the database
Accessors can be bypassed by using the getRawAttributes function to retrieve all of the unmodified attribute values
const rawAttributes = person.getRawAttributes();
Mutators (set)
class Person extends defineModel({
db,
table: "people",
attributes: {
name: {
set: (value) => value.toUpperCase(),
},
},
}) {}
const person = new Person({ name: "Joe" }); // the set function is run and the value is stored in uppercase in the model
const allCapsPersonName = person.name; // returns "JOE" which is the actual value of the attribute
person.save(); // The string "JOE" is written to the database
Mutators can be bypassed by using the setRawAttributes function
person.setRawAttributes({ name: "Joe" }); // this will not trigger the mutator
Computed Values
If you have a computed value which you'd like to add to a model, you can do so by adding a get function for that "fake" attribute. This attribute will be defined as read-only, and will show a type error if you try to modify it.
export default class Pet extends defineModel({
// ... model config
}) {
// this is in the class, not the defineModel config
get upperName() {
// returns the name of the pet in all uppercase
return this.name.toUpperCase();
}
}
The attribute can then be read directly on the model object
const upperName = pet.upperName; // upperName shows in intellisense and appears as read-only attribute on the model
While accessors and mutators (get/set) on an attribute need to accept and return the original type, computed attributes can return anything you want!
class Person extends defineModel({
db,
table: "people",
}) {
get splitName() {
return {
firstName: this.name.split(" ")[0],
lastName: this.name.split(" ")[1],
};
}
}
Model Functions
Basic Model Functions
You can define your own functions on your models to handle common operations.
As an example, let's say we want to have a function to increment a counter on our model. We can define this function on our model like so:
export default class Pet extends defineModel({
// ...model config
}) {
// increment a counter and save the model in a single call
incrementCounter() {
console.log("Incrementing counter for pet:", this.attributes.name);
this.attributes.counter += 1;
return this.save();
}
}
And then that function can be called on the model instance directly.
const pet = await Pet.findOrFail(1);
await pet.incrementCounter(); // This function will increment the counter and save the model in a single call
Type Safety in Model Functions
Custom functions on a model that you write may require specific columns to have been selected. In the example above, the incrementCounter function wouldn't work if you hadn't selected the counter field when retrieving your model.
This can be resolved by adding requirements to your function so that it will only work on models which have been selected with the required fields.
Use the RequireSelected utility type to specify that a function will require that certain fields are selected.
import { defineModel, RequireSelected } from "vasta-orm";
...
export default class Pet extends defineModel({
// config ...
},
}) {
// Restrict 'this' to require the 'counter' attribute
incrementCounter(this: RequireSelected<Pet, "counter">) {
this.attributes.counter += 1;
}
// Restrict 'this' to require BOTH 'counter' and 'id', since id is required to save the model back to the database
async incrementAndSave(this: RequireSelected<Pet, "counter" | "id">) {
this.incrementCounter(); // Valid, because we required "counter"
await this.save(); // Valid, because we required "id" (the primary key)
}
// This function will not show any type errors if you attempt to call this on a model without the required 'counter' field
incrementCounterWithoutSafety() {
this.attributes.counter += 1;
}
}
type Requires<K extends keyof Pet["attributes"] & string> = RequireSelected<Pet, K>;
// ...
incrementCounter(this: Requires<"counter">)
Here's an example of how this would work:
// We haven't selected the counter field
const pet = await Pet.select(["name"]).where("name", "Zuko").firstOrFail();
// This will show a type error because the counter field was not selected and it is required for this function
pet.incrementCounter();
// This will not show a type error because there are no columns specified as required
pet.incrementCounterWithoutSafety();
// Select the pet with the 'counter' field
const goodPet = await Pet.select(["name", "counter"]).where("name", "Zuko").firstOrFail();
// This will not show a type error because we have the required fields.
goodPet.incrementCounter();