Entities
Entities are the primary building blocks of a model, and correspond to the core concepts in your application domain, such as users, products, orders, etc. Entities have identity, and are uniquely identifiable by their primary key. They are made up of fields, which represent the properties of the entity, and relationships, which represent the associations between entities.
In Verse, entity models are created using the entity
configuration function,
which is used, along with other configuration functions, to define a complete entity model. The following example
demonstrates how to define a simple entity model for a user:
const User = entity({
id: int(),
name: string(),
email: string(),
});
Once created, we can add it to the model by simply adding it to the entities
object in the model configuration:
const db = verse({
config: {
// ...
},
model: {
entities: {
users: User,
},
},
});
We could have just as easily defined the entity directly in the model configuration, but it's often better to define it separately, especially when the model is large or the entity is involved in relationships.
Observe that when we add an entity to the model, we associate it with a label
, in this case users
. This label is used
to refer to the entity when we are doing things like querying data.
E.g. it lets us write queries like db.from.users.where(u => u.name === 'Alice')
.
Objects vs. Classes
At runtime, Verse entities can either be plain objects or class instances. By default, entities are plain objects, but you can also use classes to represent entities. This can be useful when you want to add methods to your entities, or when you want to use inheritance to define common behavior.
To use a a class to define an entity, you can simply pass the class constructor to the entity
function:
class User {
constructor(
readonly id: number,
public name: string,
public email: string
) {}
}
const user = entity(User, {
id: int(),
name: string(),
email: string(),
});
In this case, we define a User
class with a constructor that takes the properties of the entity as arguments. We then
pass the class constructor to the entity
function, along with the configuration object that defines the properties of
the entity.
All configuration APIs that accept entity types can also accept class constructors.
Whether or not you use classes to define entities is largely a matter of personal preference, but there are some things that you can do with classes that you can't do with plain objects:
- Use inheritance to define common behavior.
- Create entities with stricter APIs. e.g. by making properties read-only.
- Use shadow properties to represent properties that are not present in the class definition.
- Easily create circular references between entities.
- Perform in-line model configuration.
Entity Types
When using plain objects to define entities, we would like a type that represents the shape of the entity. Verse
provides a utility type, EntityType
, that generates a type for an
entity. This type can be used to type-check entities and queries, and to provide additional tooling support.
type UserType = EntityType<typeof db.entities.users>;
Circular References
Entities can have relationships with other entities, and these relationships can form a graph of entities that reference each other. This is known as a circular reference, and can be useful for representing complex relationships between entities. When using class-based entities, this is not a problem, as classes can reference each other directly. However, when using plain objects, we need to use a different approach to handle circular references, because TypeScript does not perform type-inference for types containing circular references.
The pattern we need to use looks like this:
type PostType = {
readonly id: number;
userId: number;
};
const User = entity({
id: int(),
posts: many<PostType>("Post"),
});
const Post = entity({
id: int(),
userId: int(),
user: one(User),
});
const db = verse({
config: {
// ...
},
model: {
entities: {
users: User,
posts: Post,
},
},
});
We can see that the main difference is that we have had to explicitly define the type of the Post
entity, and then
pass it to the many
function when defining the posts
navigation property on the User
entity. Additionally, we
have had to pass the name of the Post
entity to the many
function, so that Verse can resolve the entity that the
navigation is referencing. The name of an entity is just the singularized, pascal-cased version of the entity label,
and is used internally by Verse to identify entities.
Properties
As shown above, properties are specified by passing a configuration object to the entity
function. The configuration
object keys are the names of the properties, and the values are calls to type-specific configuration functions. In this
case, we used the int
and string
functions to define the id
, name
, and email
properties. There are also configuration functions for defining
other types of properties, such as boolean
, date
and number
.
These scalar property configuration functions are also used to define how the property behaves and how it should be mapped to the database.
For example we can can override the default column name for a property:
const User = entity({
id: int({ column: "user_id" }),
name: string(),
email: string(),
});
Or set the maximum length of a string property:
const User = entity({
id: int(),
name: string({ maxLength: 255 }),
email: string(),
});
Shadow Properties
When using class-based entities, we can define shadow properties on the entity to represent properties that are not present in the class definition, but are used by the entity model. Shadow properties are useful for things like optimistic concurrency control, where we need to store a version number for the entity, but don't want to expose it as a property on the class.
For example, we can define a shadow property for the User
entity to represent the version number:
class User {
constructor(
readonly id: number,
public name: string
) {}
}
const user = entity(
User,
{
id: int(),
name: string(),
version: int(),
},
e => e.concurrency({ version: "version" })
);
Verse will treat the version
property just like any other property, but it will not be present in the type of the
entity, and so will not be visible to consumers of the entity.
Navigations
Navigations are entity properties that reference other entities. They are used to represent relationships between entities so that we can enjoy a rich and natural programming model that reflects the data structure of our application domain. Specifically, they allow us to do things like: eager load related data, query across relations without the need for manual joins, and update aggregate roots with their children in a single transaction.
For example, a User
entity might have a posts
navigation property that references a collection of Post
entities that the user has created.
const Post = entity({
id: int(),
title: string(),
content: string(),
});
const User = entity({
id: int(),
name: string(),
email: string(),
posts: many(Post),
});
We use the many
function to define a navigation property that references a
collection of entities. We can also use the one
function to define a navigation
property that references a single entity.
Key
Entities have a primary key, which is a unique identifier for the entity. By default, Verse tries to infer the primary key based on the properties of the entity using conventions. However, you can, of course, explicitly specify the primary key using configuration:
const User = entity(
{
id: int(),
name: string(),
email: string(),
},
e => e.key("id")
);
In this case, we explicitly specify that the id
property is the primary key of the entity by calling the key
method
on an entity configuration builder object. We pass a function that takes the entity configuration builder as the second
argument to the entity
function.
Foreign Keys
Foreign keys are used to define relationships between entities at the database level, and underpin things like navigations
and cascading updates. In Verse, foreign keys are defined using the references
method on the entity configuration builder:
const User = entity({
id: int(),
//...
});
const Post = entity(
{
id: int(),
userId: int(),
//...
},
e => e.references(User)
);
Observe that, in this case, we don't need to specify the column name for the foreign key property, as Verse will infer it using conventions. However, we can override the default behavior by specifying the column name explicitly:
const User = entity({
id: int(),
//...
});
const Post = entity(
{
id: int(),
userFk: int(),
//...
},
e => e.references(User, "userFk")
);
Here, the foreign key property is named userFk
, and so will not be automatically inferred by Verse. We need to specify
the column name explicitly when calling the references
method.
Table
We can also specify the table name for an entity, using the same fluent configuration builder function:
const User = entity(
{
id: int(),
name: string(),
email: string(),
},
e => e.table("users")
);
In Verse, explicit configuration is often not necessary, as the framework can infer most of the configuration from conventions. However, explicit configuration can be useful when you need to override the default behavior.
Concurrency
Entities can also have a concurrency property, which is used to implement optimistic concurrency control. This is a
mechanism to prevent lost updates in a multi-user environment. When an entity has a concurrency property, Verse will
automatically include it in the WHERE
clause of UPDATE
and DELETE
statements, and check that the value has not
changed since the entity was last read.
To specify a concurrency property, we can use the concurrency
method on the entity configuration builder:
const User = entity(
{
//...
token: int(),
},
e => e.concurrency({ version: "token" })
);
Here, we specify that the token
property is the concurrency property for the entity. The version
option is used to
specify that we are using the version strategy for concurrency control. This means that the property is expected to
be a monotonically increasing value, and will be incremented by one each time the entity is updated. Verse will
manage this for us automatically.
Conditions
Entities can also have conditions, which are filters that are automatically applied to any queries for that entity.
Conditions are specified using the condition
method on the entity configuration builder:
const User = entity(
{
//...
deleted: boolean(),
},
e => e.condition(user => !user.deleted)
);
Conditions are useful for implementing cross-cutting concerns, such as soft deletes, where entities are not actually
deleted from the database, but are instead marked as deleted by setting a deleted
flag to true
. By adding a
condition to the entity, we can ensure that developers do not have to remember to filter out deleted entities in their
queries.
Seed Data
Entities can also have seed data, which is initial data, defined at the model level. Verse will automatically insert
this data into the database when the database is created. Seed data is specified using the data
method on the entity
configuration builder:
const User = entity(
{
id: int(),
name: string(),
email: string(),
},
e =>
e.data(
{ id: 1, name: "John", email: "john@example.com" },
{ id: 2, name: "Jane", email: "jane@example.com" }
)
);
Seed data is useful for populating the database with initial data, such as reference data, that is required for the application to function correctly. It can also be used for testing purposes, to ensure that the database is in a known state before running tests.