Language: English | 日本語
Accel Record
Accel Record is a type-safe and synchronous ORM for TypeScript. It adopts the Active Record pattern and is heavily influenced by Ruby on Rails' Active Record.
It uses Prisma for schema management and migration, and you can also use existing Prisma schemas as is.
It can be used with MySQL, PostgreSQL, and SQLite.
Features
- Active Record pattern
- Type-safe classes
- Native ESM
- Synchronous API
- Support for MySQL, PostgreSQL, and SQLite
Table of Contents
Usage
For example, if you define a User model like this:
model User {
id Int @id @default(autoincrement())
firstName String
lastName String
age Int?
}
You can write domain logic as follows:
import { User } from "./models/index.js";
const user: User = User.create({
firstName: "John",
lastName: "Doe",
});
user.update({
age: 26,
});
for (const user of User.all()) {
console.log(user.firstName);
}
const john: User | undefined = User.findBy({
firstName: "John",
lastName: "Doe",
});
john.delete();
You can also extend models and define custom methods as you like.
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends ApplicationRecord {
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
import { User } from "./models/index.js";
const user = User.create({
firstName: "John",
lastName: "Doe",
});
console.log(user.fullName);
Installation
-
Install the npm package:
npm install accel-record
-
Install a database driver:
Quick Start
Here is an example for MySQL.
$ npm install accel-record mysql2
$ npx prisma init
By defining the Prisma schema as shown below and calling initAccelRecord
, you can connect to the database.
generator client {
provider = "prisma-client-js"
}
generator custom_generator {
provider = "prisma-generator-accel-record"
output = "../src/models"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
firstName String
lastName String
age Int?
}
import { initAccelRecord } from "accel-record";
import { getDatabaseConfig, User } from "./models/index.js";
initAccelRecord(getDatabaseConfig()).then(() => {
User.create({
firstName: "John",
lastName: "Doe",
});
console.log(`New user created! User.count is ${User.count()}`);
});
$ export DATABASE_URL="mysql://root:@localhost:3306/accel_test"
$ npx prisma migrate dev
$ npm i -D tsx
$ npx tsx src/index.ts
New user created! User.count is 1
Examples
Creating and Saving Data
import { NewUser, User } from "./models/index.js";
const user: User = User.create({
firstName: "John",
lastName: "Doe",
});
console.log(user.id);
const user: NewUser = User.build({});
user.firstName = "Alice";
user.lastName = "Smith";
user.save();
console.log(user.id);
Retrieving Data
import { User } from "./models/index.js";
const allUsers = User.all();
console.log(`IDs of all users: ${allUsers.map((u) => u.id).join(", ")}`);
const firstUser = User.first();
console.log(`Name of the first user: ${firstUser?.firstName}`);
const john = User.findBy({ firstName: "John" });
console.log(`ID of the user with the name John: ${john?.id}`);
const does = User.where({ lastName: "Doe" });
console.log(`Number of users with the last name Doe: ${does.count()}`);
Updating Data
import { User } from "./models/index.js";
const user = User.first()!;
user.update({ age: 26 });
user.age = 26;
user.save();
Deleting Data
import { User } from "./models/index.js";
const user = User.first()!;
user.delete();
user.destroy();
Model Types
NewModel and PersistedModel
Accel Record provides two types, NewModel
and PersistedModel
, to distinguish between newly created and saved models.
Depending on the schema definition, some properties in NewModel
allow undefined
, while PersistedModel
does not.
This allows you to handle both pre-save and post-save models in a type-safe manner.
import { User, NewUser } from "./models/index.js";
const newUser: NewUser = User.build({});
const persistedUser: User = User.first()!;
BaseModel
The above NewModel
and PersistedModel
inherit from BaseModel
.
Methods defined in BaseModel
can be used by both NewModel
and PersistedModel
.
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends ApplicationRecord {
get fullName(): string | undefined {
if (!this.firstName || !this.lastName) {
return undefined;
}
return `${this.firstName} ${this.lastName}`;
}
}
import { User, NewUser } from "./models/index.js";
const newUser: NewUser = User.build({});
console.log(newUser.fullName);
const user: User = User.first()!;
console.log(user.fullName);
You can also define methods that are type-safe and can only be used by PersistedModel
by specifying the this
type for the method. (In this case, the get
keyword cannot be used due to TypeScript specifications)
import { ApplicationRecord } from "./applicationRecord.js";
import { User } from "./index.js";
export class UserModel extends ApplicationRecord {
fullName(this: User): string {
return `${this.firstName} ${this.lastName}`;
}
}
import { User, NewUser } from "./models/index.js";
const newUser: NewUser = User.build({});
newUser.fullName();
const user: User = User.first()!;
console.log(user.fullName());
Converting from NewModel to PersistedModel
By using methods like save()
and isPersisted()
, you can convert a NewModel
type to a PersistedModel
type.
import { User, NewUser } from "./models/index.js";
const user: NewUser = User.build({
firstName: "John",
lastName: "Doe",
});
if (user.save()) {
console.log(user.id);
} else {
console.log(user.id);
}
const someFunc = (user: NewUser | User) => {
if (user.isPersisted()) {
console.log(user.id);
} else {
console.log(user.id);
}
};
Prisma Schema and Field Types
Accel Record uses Prisma for schema definition, and the support status for each feature is as follows:
Feature | Notation | Support |
---|
ID | @id | ✅ |
Multi-field ID (Composite ID) | @@id | ✅ |
Table name mapping | @@map | ✅ |
Column name mapping | @map | ✅ |
Default value | @default | ✅ |
Updated at | @updatedAt | ✅ |
List | [] | ✅ |
Optional | ? | ✅ |
Relation field | | ✅ |
Implicit many-to-many relations | | ✅ |
Enums | enum | ✅ |
Unsupported type | Unsupported | - |
The types of NewModel and PersistedModel differ depending on whether the field type is required or optional.
Type | NewModel | PersistedModel |
---|
Required Field | Nullable | NonNullable |
Optional Field | Nullable | Nullable |
In addition, the types of NewModel and PersistedModel differ depending on how the default value is specified.
Argument | NewModel | PersistedModel |
---|
Static value | NonNullable | NonNullable |
autoincrement() | Nullable | NonNullable |
now() | Nullable | NonNullable |
dbgenerated() | Nullable | NonNullable |
uuid() | NonNullable | NonNullable |
cuid() | NonNullable | NonNullable |
Here are examples of model definitions and their corresponding NewModel and PersistedModel:
model Sample {
id Int @id @default(autoincrement())
required Int
optional String?
hasDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
uuid String @default(uuid())
cuid String @default(cuid())
}
interface NewSample {
id: number | undefined;
required: number | undefined;
optional: string | undefined;
hasDefault: boolean;
createdAt: Date | undefined;
updatedAt: Date | undefined;
uuid: string;
cuid: string;
}
interface Sample {
id: number;
required: number;
optional: string | undefined;
hasDefault: boolean;
createdAt: Date;
updatedAt: Date;
uuid: string;
cuid: string;
}
Type of Json Field
When defining a Json type in a typical Prisma schema, you cannot specify strict types.
model Sample {
id Int @id @default(autoincrement())
data Json
}
With Accel Record, you can specify the type for Json fields in the BaseModel.
In this case, you can handle Json fields in a type-safe manner for both reading and writing.
import { ApplicationRecord } from "./applicationRecord.js";
export class SampleModel extends ApplicationRecord {
data: { myKey1: string; myKey2: number } | undefined = undefined;
}
import { Sample } from "./models/index.js";
const sample = Sample.build({});
sample.data = { myKey1: "value1", myKey2: 123 };
sample.data = { foo: "value1" };
console.log(sample.data?.myKey1);
console.log(sample.data?.foo);
Associations
Below are examples of operations on models with associations.
One-to-One Relationship
model User {
id Int @id @default(autoincrement())
profile Profile?
}
model Profile {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id])
}
import { User, Profile } from "./models/index.js";
const user = User.create({});
const profile = Profile.create({ user });
user.profile = Profile.build({});
user.update({ profile: Profile.build({}) });
user.profile;
profile.user;
user.profile?.destroy();
user.profile = undefined;
One-to-Many Relationship
model User {
id Int @id @default(autoincrement())
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
}
import { User, Post } from "./models/index.js";
const user = User.create({});
const post = Post.create({ user });
user.posts.push(Post.build({}));
user.posts = [Post.build({})];
user.posts.toArray();
post.user;
user.posts.destroy(post);
post.destroy();
Many-to-Many Relationship
In Prisma schema, there are two ways to define Many-to-Many relationships: explicit and implicit.
For explicit Many-to-Many relationships, you define an intermediate table.
In this case, you would operate in a similar way as the previous One-to-Many relationship.
Here is an example of an implicit Many-to-Many relationship.
model User {
id Int @id @default(autoincrement())
groups Group[]
}
model Group {
id Int @id @default(autoincrement())
users User[]
}
import { User, Group } from "./models/index.js";
const user = User.create({});
const group = Group.create({});
user.groups.push(group);
user.groups = [group];
group.users.push(user);
group.users = [user];
user.groups.toArray();
group.users.toArray();
user.groups.destroy(group);
group.users.destroy(user);
Query Interface
Model Query Interface
Here are some examples of using the interface to perform queries on models.
Each method allows you to query the model in a type-safe manner using information generated from the model definition.
You can also take advantage of IDE autocompletion.
For more details, refer to the list of methods in the Relation class.
import { User } from "./models/index.js";
User.where({
name: "John",
age: { ">=": 18 },
email: { endsWith: "@example.com" },
})
.order("createdAt", "desc")
.includes("posts", "setting")
.offset(10)
.limit(10);
User.where({ name: ["John", "Alice"] }).exists();
User.joins("profile").where("Profile.name = ?", "John").count();
User.first()?.posts.destroyAll();
The model query interface does not currently support features like GROUP BY.
This is because these queries have limited benefits from using schema type information.
If you need to execute queries that cannot be achieved with the model query interface, please use raw SQL or the Knex QueryBuilder explained below.
Executing Raw SQL Queries
You can use the Model.connection.execute()
method to execute raw SQL queries and synchronously retrieve the results.
import { Model } from "accel-record";
const rows = Model.connection.execute(
`select firstName, count(id) as cnt
from User
group by firstName`,
[]
);
console.log(rows);
Executing Queries with Knex QueryBuilder
You can use Knex to build and execute queries.
We have added an execute()
method to Knex's QueryBuilder, which allows you to execute queries synchronously.
For more details on the functionality, refer to the following link:
Knex Query Builder | Knex.js
import { Model } from "accel-record";
import { User } from "./models/index.js";
const knex = Model.connection.knex;
const rows = knex
.select("name", knex.raw("SUM(score) as total"))
.from("Score")
.groupBy("name")
.execute();
console.log(rows);
const rows = User.queryBuilder.select("name").groupBy("name").execute();
console.log(rows);
Scopes
You can define the content of reusable queries as scopes.
To define a scope, create a static class method on the model and decorate it with @scope
.
After that, run prisma generate
to reflect the scopes in the query interface.
import { scope } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends ApplicationRecord {
@scope
static johns() {
return this.where({ name: "John" });
}
@scope
static adults() {
return this.where({ age: { ">=": 20 } });
}
}
With the above definition, you can use the scopes as follows:
import { User } from "./models/index.js";
User.johns().adults().count();
Flexible Search
Using the .search()
method, you can perform object-based flexible searches.
(The interface is inspired by the Ransack gem.)
Search parameters are specified as an object with keys representing the field name and search condition combination strings, and values representing the search values.
You can include associations in the keys.
The search conditions include eq
, cont
, matches
, lt
, gte
, in
, null
, and more.
In addition, modifiers such as not
, or
, and
, any
, all
are also available.
Please refer to the documentation of the search() method for more details.
import { User } from "./models/index.js";
const search = User.search({
name_eq: "John",
age_not_null: 1,
profile_bio_cont: "foo",
email_or_name_cont_any: ["bar", "baz"],
});
const users = search.result();
Additionally, you can include the names of searchable scopes defined in the searchableScopes
array as keys in the search parameters.
For example, the bio_cont
scope defined as follows can be used in the search parameters:
import { scope } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
class UserModel extends ApplicationRecord {
@scope
static bio_cont(value: string) {
return this.joins("profile").where({
profile: { bio: { contains: value } },
});
}
static searchableScopes = ["bio_cont"];
}
import { User } from "./models/index.js";
const search = User.search({ bio_cont: "foo" });
const users = search.result();
Testing
Testing with Vitest
In Vitest, you prepare a setup file like the following for testing.
import { DatabaseCleaner, Migration, initAccelRecord, stopWorker } from "accel-record";
import path from "path";
import { fileURLToPath } from "url";
import { getDatabaseConfig } from "../src/models/index.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
beforeAll(async () => {
await initAccelRecord({
...getDatabaseConfig(),
datasourceUrl: `mysql://root:@localhost:3306/accel_test${process.env.VITEST_POOL_ID}`,
});
await Migration.migrate();
});
beforeEach(async () => {
DatabaseCleaner.start();
});
afterEach(async () => {
DatabaseCleaner.clean();
});
afterAll(async () => {
stopWorker();
});
By specifying the above file in the setupFiles of the Vitest configuration file, you can initialize the database before running the tests.
For more details, refer to the Vitest documentation.
export default {
test: {
globals: true,
setupFiles: ["./tests/vitest.setup.ts"],
},
};
Model Factory
To generate test records, you can use a Factory.
Please refer to accel-record-factory for more details.
import { defineFactory } from "accel-record-factory";
import { User } from "../../src/models/index.js";
export const UserFactory = defineFactory(User, {
firstName: "John",
lastName: "Doe",
age: 20,
});
export { UserFactory as $User };
import { $User } from "./factories/user";
const newUser = $User.build();
newUser.firstName;
newUser.lastName;
newUser.age;
Validation
Sample Validation
Here is an example of validation for a model.
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends ApplicationRecord {
override validateAttributes() {
this.validates("firstName", { presence: true });
}
}
import { User } from "./models/index.js";
const user = User.build({ firstName: "" });
user.isValid();
user.errors.fullMessages();
user.firstName = "John";
user.isValid();
Validation Execution Timing
When using the save
, update
, and create
methods, validation is automatically executed, and the saving process is only performed if there are no errors.
import { User } from "./models/index.js";
const newUser = User.build({ firstName: "" });
newUser.save();
newUser.errors.fullMessages();
const user = User.first()!;
user.update({ firstName: "" });
newUser.errors.fullMessages();
User.create({ firstName: "" });
Validation Definition
You can define validations by overriding the validateAttributes
method of the BaseModel.
model ValidateSample {
id Int @id @default(autoincrement())
accepted Boolean
pattern String
key String
count Int
size String
}
import { Validator } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
export class ValidateSampleModel extends ApplicationRecord {
override validateAttributes() {
this.validates("accepted", { acceptance: true });
this.validates("pattern", {
length: { minimum: 2, maximum: 5 },
format: { with: /^[a-z]+$/, message: "only allows lowercase letters" },
});
this.validates("size", { inclusion: { in: ["small", "medium", "large"] } });
this.validates(["key", "size"], { presence: true });
this.validates("key", { uniqueness: true });
if (this.key && !/^[a-z]$/.test(this.key[0])) {
this.errors.add("key", "should start with a lowercase letter");
}
this.validatesWith(new MyValidator(this));
}
}
class MyValidator extends Validator<{ key: string | undefined }> {
validate() {
if (this.record.key === "xs") {
this.errors.add("key", "should not be xs");
}
}
}
Callbacks
By using the before
and after
decorators, you can define callbacks in your models to perform actions before or after validation or saving records.
The targets for callbacks are validation
, save
, create
, update
, and destroy
.
The feature is available in environments where Stage 3 decorators, implemented in TypeScript 5.0, are supported.
import { ApplicationRecord } from "./applicationRecord.js";
export class CallbackSampleModel extends ApplicationRecord {
@before("save")
beforeSave() {
}
@after("create")
afterCreate() {
}
}
Serialization
By using the toHash
and toHashArray
methods, you can convert the model's data into plain objects.
import { User } from "./models/index.js";
const userHash = User.first()!.toHash({
only: ["firstName", "lastName"],
include: { posts: { only: ["title"] } },
});
console.log(userHash);
const usersHashArray = User.all().toHashArray({
only: ["firstName", "lastName"],
});
console.log(usersHashArray);
By using the toJson
method, you can convert the model's data into a JSON string.
import { User } from "./models/index.js";
const userJson = User.first()!.toHah({
only: ["firstName", "lastName"],
include: { posts: { only: ["title"] } },
});
console.log(userJson);
const usersJson = User.all().toHashArray({
only: ["firstName", "lastName"],
});
console.log(usersJson);
Bulk Insert
Bulk Insert is a feature that allows you to insert multiple records into the database at once.
In Accel Record, you can use the import()
method to perform Bulk Insert.
import { User } from "./models/index.js";
const users = [
User.build({ id: 1, firstName: "Foo", lastName: "Bar" }),
User.build({ id: 2, firstName: "John", lastName: "Doe" }),
];
User.import(users, {
onDuplicateKeyUpdate: ["firstName", "lastName"],
validate: "throw",
});
Transactions
You can use the Model.transaction()
method to utilize transactions. By throwing a Rollback
exception, you can rollback the transaction, and transactions can be nested.
import { Rollback } from "accel-record";
import { User } from "./models/index.js";
User.transaction(() => {
User.create({});
console.log(User.count());
User.transaction(() => {
User.create({});
console.log(User.count());
throw new Rollback();
});
});
console.log(User.count());
Lock
You can perform row locking using the lock()
and withLock()
methods. (Supported in MySQL and PostgreSQL)
import { User } from "./models/index.js";
User.transaction(() => {
const user1 = User.lock().find(1);
const user2 = User.lock().find(2);
user1.point += 100;
user2.point -= 100;
user1.save();
user2.save();
});
const user = User.find(1);
user.withLock(() => {
user.update({ name: "bar" });
});
Internationalization (I18n)
We provide internationalization functionality using i18next
.
You can reference the translation of model names and attribute names by using the Model.model_name.human
method and the Model.human_attribute_name(attribute)
method.
import i18next from "i18next";
import { User } from "./models/index.js";
i18next
.init({
lng: "en",
resources: {
en: {
translation: {
"accelrecord.models.User": "User",
"accelrecord.attributes.User.firstName": "First Name",
"accelrecord.attributes.User.lastName": "Last Name",
},
},
},
})
.then(() => {
console.log(User.modelName.human);
console.log(User.humanAttributeName("firstName"));
});
Translation of Error Messages
Validation error messages are also translatable and can be referenced using the following keys:
accelrecord.errors.models.[ModelName].attributes.[attribute].[messageKey]
accelrecord.errors.models.[ModelName].[messageKey]
accelrecord.errors.messages.[messageKey]
errors.attributes.[attribute].[messageKey]
errors.messages.[messageKey]
import { ApplicationRecord } from "./applicationRecord.js";
class UserModel extends ApplicationRecord {
override validateAttributes() {
this.validates("firstName", { presence: true });
}
}
In the example above, the translation of the message key 'blank'
will be used for the error message.
In this example, the following keys will be searched in order, and the first key found will be used:
accelrecord.errors.models.User.attributes.name.blank
accelrecord.errors.models.User.blank
accelrecord.errors.messages.blank
errors.attributes.name.blank
errors.messages.blank
import i18next from "i18next";
import { User } from "./models/index.js";
i18next
.init({
lng: "en",
resources: {
en: {
translation: {
"accelrecord.models.User": "User",
"accelrecord.attributes.User.firstName": "First Name",
"accelrecord.attributes.User.lastName": "Last Name",
"accelrecord.errors.messages.blank": "can't be blank",
},
},
},
})
.then(() => {
const user = User.build({});
user.validate();
console.log(User.errors.fullMessages);
});
The message keys corresponding to each validation are as follows:
Validation | Option | Message Key | Interpolation |
---|
acceptance | - | 'accepted' | - |
presence | - | 'blank' | - |
length | 'minimum' | 'tooShort' | count |
length | 'maximum' | 'tooLong' | count |
uniqueness | - | 'taken' | - |
format | - | 'invalid' | - |
inclusion | - | 'inclusion' | - |
numericality | 'equalTo' | 'equalTo' | count |
For those with interpolation set to count
, that part will be replaced with the value specified in the option when the error message contains {{count}}
.
Translation of Enums
You can define translations for each value of an Enum.
enum Role {
MEMBER
ADMIN
}
model User {
role Role @default(MEMBER)
}
You can use User.role.options()
to retrieve the translations corresponding to each value of the Enum.
For each User
with a role
, you can retrieve the translation corresponding to the Enum value using the roleText
property.
import i18next from "i18next";
import { User } from "./models/index.js";
i18next
.init({
lng: "ja",
resources: {
ja: {
translation: {
"enums.User.Role.MEMBER": "Member",
"enums.User.Role.ADMIN": "Admin",
},
},
},
})
.then(() => {
User.role.options();
const user = User.build({});
user.role;
user.roleText;
});
In the example of user.role
, the following keys will be searched in order, and the first key found will be used:
enums.User.Role.MEMBER
enums.defaults.Role.MEMBER
enums.Role.MEMBER
Password Authentication
We provide a mechanism for securely hashing and authenticating passwords using Bcrypt.
First, add a passwordDigest
field to the model to store the hashed password.
model User {
...
passwordDigest String
}
Next, use hasSecurePassword()
to add functionality to the model for hashing and authenticating passwords.
import { hasSecurePassword, Mix } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends Mix(ApplicationRecord, hasSecurePassword()) {}
With this, you can perform password validation and hashing using the password
and passwordConfirmation
fields, and authenticate passwords using the authenticate()
method.
import { User } from "./models/index.js";
const user = User.build({});
user.password = "";
user.save();
user.password = "myPassword";
user.save();
user.passwordConfirmation = "myPassword";
user.save();
user.authenticate("invalid");
user.authenticate("myPassword");
You can customize the field name for storing the password by setting it to something other than passwordDigest
, and you can manage multiple passwords in the model as well.
import { hasSecurePassword, Mix } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends Mix(
ApplicationRecord,
hasSecurePassword(),
hasSecurePassword({ attribute: "recovery", validation: false })
) {}
Form Objects
Form objects are a design pattern that allows you to separate validation and saving logic from regular models. They are used for handling processes that span multiple models or for handling form processing that doesn't correspond to regular models.
By inheriting from the FormModel
class, you can define attributes and perform validations just like regular models, even though the form object is not directly related to a database table.
import { FormModel } from "accel-record";
import { attributes } from "accel-record/attributes";
class MyForm extends FormModel {
title = attributes.string();
priority = attributes.integer(3);
dueDate = attributes.date();
override validateAttributes() {
this.validates("title", { presence: true });
this.validates("priority", { numericality: { between: [1, 5] } });
}
save() {
if (this.isInvalid()) return false;
return true;
}
}
const myFormParams = { title: "Task", priority: "2", dueDate: "2022-12-31" };
const form = MyForm.build(myFormParams);
if (form.save()) {
} else {
const errorMessages = form.errors.fullMessages();
}
Nullable Values Handling
Regarding nullable values, TypeScript, like JavaScript, has two options: undefined and null.
For Accel Record, there is no need to use null, and nullable values are consistently represented as undefined.
This is mainly to avoid the complexity of mixing null and undefined.
While we understand that there are benefits to using undefined and null differently, we prioritize code readability and maintainability by avoiding the complexity of types.
import { User } from "./models/index.js";
const newUser = User.build({});
newUser.age;
const user = User.findBy({ age: undefined })!;
user.age;
user.update({ age: undefined });
Future Planned Features
Accel Record Roadmap
Background of Design and Development
Introducing articles about the motivation behind the design and development of Accel Record.