Accel Recordは、型安全で同期的な、TypeScript用のORMです。
Active Recordパターンを採用しており、インターフェースはRuby on RailsのActiveRecordに強く影響を受けています。
スキーマ管理とマイグレーションにはPrismaを利用しており、既存のPrismaスキーマをそのまま利用することもできます。
MySQL, PostgreSQL, SQLiteでの利用が可能です。
- ActiveRecordパターン
- 型安全なクラス
- Native ESM
- 同期的なAPI
- MySQL, PostgreSQL, SQLiteのサポート
- 利用例
- Installation
- Quick Start
- Examples
- モデルの型
- Prismaスキーマとフィールドの型
- Jsonフィールドの型
- 関連付け
- クエリインターフェース
- スコープ
- Flexible Search
- テスト
- バリデーション
- コールバック
- Serialization
- Bulk Insert
- トランザクション
- ロック
- 国際化(I18n)
- パスワード認証
- Formオブジェクト
- Nullableな値の扱いについて
- 今後予定されている機能追加
例えば以下のようにUserモデルを定義した場合、
// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
firstName String
lastName String
age Int?
}
以下のようにドメインロジックを記述することができます。
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();
また、モデルを拡張して自由にメソッドを定義できます。
// src/models/user.ts
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); // => "John Doe"
-
Install the npm package:
npm install accel-record
-
Install a database driver:
-
for MySQL or MariaDB
npm install mysql2
-
for PostgreSQL
npm install pg
-
for SQLite
npm install better-sqlite3
-
MySQLでの例を記載します。
$ npm install accel-record mysql2
$ npx prisma init
以下のようにPrismaのスキーマを定義し、initAccelRecord
を呼び出すことで、データベースに接続することができます。
// prisma/schema.prisma
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?
}
// src/index.ts
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
# tsxを使って.tsファイルを実行する例
$ npm i -D tsx
$ npx tsx src/index.ts
New user created! User.count is 1
import { NewUser, User } from "./models/index.js";
// Create a user
const user: User = User.create({
firstName: "John",
lastName: "Doe",
});
console.log(user.id); // => 1
// You can also write it like this
const user: NewUser = User.build({});
user.firstName = "Alice";
user.lastName = "Smith";
user.save();
console.log(user.id); // => 2
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()}`);
import { User } from "./models/index.js";
const user = User.first()!;
user.update({ age: 26 });
// You can also write it like this
user.age = 26;
user.save();
import { User } from "./models/index.js";
const user = User.first()!;
// Delete a record
user.delete();
// Alternatively, delete with associations
user.destroy();
Accel Recordでは、新規作成されたモデルと保存済みのモデルを区別するために、それぞれNewModel
とPersistedModel
という型を提供しています。
スキーマ定義に応じて一部のプロパティにおいては、NewModel
ではundefinedを許容しPersistedModel
ではundefinedを許容しない型となります。
これにより、保存前のモデルと保存後のモデルをどちらも型安全に扱うことができます。
import { User, NewUser } from "./models/index.js";
/*
NewModelの例:
NewUser型 は新規作成された保存前のモデルを表し、以下のような型となります。
interface NewUser {
id: number | undefined;
firstName: string | undefined;
lastName: string | undefined;
age: number | undefined;
}
*/
const newUser: NewUser = User.build({});
/*
PersistedModelの例:
User型 は保存済みのモデルを表し、以下のような型となります。
interface User {
id: number;
firstName: string;
lastName: string;
age: number | undefined;
}
*/
const persistedUser: User = User.first()!;
上記のNewModelとPersistedModelは、BaseModelを継承しています。 BaseModelに定義されたメソッドは、NewModelとPersistedModelの両方で利用することができます。
// src/models/user.ts
import { ApplicationRecord } from "./applicationRecord.js";
/*
BaseModelの例:
UserModelはNewUserとUserに対応するBaseModelとなります。
*/
export class UserModel extends ApplicationRecord {
// ここで定義したメソッドはNewUserとUserの両方で利用することができます。
get fullName(): string | undefined {
if (!this.firstName || !this.lastName) {
// NewUserではfirstNameとlastNameがundefinedの可能性を考慮する必要があります。
return undefined;
}
return `${this.firstName} ${this.lastName}`;
}
}
import { User, NewUser } from "./models/index.js";
const newUser: NewUser = User.build({});
console.log(newUser.fullName); // => undefined
const user: User = User.first()!;
console.log(user.fullName); // => "John Doe"
またメソッドにthisの型を指定することで、PersistedModelのみで型安全に利用できるメソッドを定義することもできます。(この場合はTypeScriptの仕様により、getキーワードを利用することができません)
// src/models/user.ts
import { ApplicationRecord } from "./applicationRecord.js";
import { User } from "./index.js";
export class UserModel extends ApplicationRecord {
// このメソッドはUserのみで型安全に利用することができ、NewUserで利用した場合には型エラーとなります。
fullName(this: User): string {
return `${this.firstName} ${this.lastName}`;
}
}
import { User, NewUser } from "./models/index.js";
const newUser: NewUser = User.build({});
// @ts-expect-error
newUser.fullName();
// => The 'this' context of type 'NewUser' is not assignable to method's 'this' of type 'User'.
const user: User = User.first()!;
console.log(user.fullName()); // => "John Doe"
save()
やisPersisted()
等のメソッドを利用することで、NewModel型をPersistedModel型に変換することができます。
import { User, NewUser } from "./models/index.js";
// NewModel型のユーザーを用意
const user: NewUser = User.build({
firstName: "John",
lastName: "Doe",
});
if (user.save()) {
// saveが成功した場合、NewModelはPersistedModelに変換されます。
// このブロック中では、userはUser型として扱うことができます。
console.log(user.id); // user.idは number型
} else {
// saveが失敗した場合、NewModelはそのままの型です。
// このブロック中では、userはNewUser型のままになります。
console.log(user.id); // user.idは number | undefined型
}
const someFunc = (user: NewUser | User) => {
if (user.isPersisted()) {
// isPersisted()がtrueの場合、NewModelはPersistedModelに変換されます。
// このブロック中では、userはUser型として扱うことができます。
console.log(user.id); // user.idは number型
} else {
// isPersisted()がfalseの場合、NewModelはそのままの型です。
// このブロック中では、userはNewUser型のままになります。
console.log(user.id); // user.idは number | undefined型
}
};
Accel Recordはスキーマ定義にPrismaを利用していますが、各機能のサポート状況は以下の通りです。
機能 | 記法 | サポート |
---|---|---|
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 | - |
フィールドタイプが必須の場合とオプションの場合で、NewModelとPersistedModelの型が異なります。
type | NewModel | PersistedModel |
---|---|---|
Required Field | Nullable | NonNullable |
Optional Field | Nullable | Nullable |
また、デフォルト値の指定方法によってもNewModelとPersistedModelの型が異なります。
arg | NewModel | PersistedModel |
---|---|---|
static value | NonNullable | NonNullable |
autoincrement() | Nullable | NonNullable |
now() | Nullable | NonNullable |
dbgenerated() | Nullable | NonNullable |
uuid() | NonNullable | NonNullable |
cuid() | NonNullable | NonNullable |
以下に、モデル定義とそれに対応するNewModelとPersistedModelの例を示します。
// prisma/schema.prisma
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())
}
// NewModel
interface NewSample {
id: number | undefined;
required: number | undefined;
optional: string | undefined;
hasDefault: boolean;
createdAt: Date | undefined;
updatedAt: Date | undefined;
uuid: string;
cuid: string;
}
// PersistedModel
interface Sample {
id: number;
required: number;
optional: string | undefined;
hasDefault: boolean;
createdAt: Date;
updatedAt: Date;
uuid: string;
cuid: string;
}
通常PrismaスキーマでJson型を定義する場合、厳密な型を指定できません。
// prisma/schema.prisma
model Sample {
id Int @id @default(autoincrement())
data Json // Jsonフィールドには厳密な型がありません
}
Accel RecordではBaseModelにおいてJson型のフィールドに対して型を指定することができます。
この場合は、Json型のフィールドも読み書きともに型安全に扱うことができます。
// src/models/sample.ts
import { ApplicationRecord } from "./applicationRecord.js";
export class SampleModel extends ApplicationRecord {
// BaseModel上でJson型のフィールドに対して型を指定できる
data: { myKey1: string; myKey2: number } | undefined = undefined;
}
import { Sample } from "./models/index.js";
const sample = Sample.build({});
// OK
sample.data = { myKey1: "value1", myKey2: 123 };
// Type Error !
sample.data = { foo: "value1" };
// => Type '{ foo: string; }' is not assignable to type '{ myKey1: string; myKey2: number; } | undefined'.
// OK
console.log(sample.data?.myKey1);
// Type Error !
console.log(sample.data?.foo);
// => Property 'foo' does not exist on type '{ myKey1: string; myKey2: number; } | undefined'.
以下に、関連付けを持つモデルに対する操作の例を示します。
// prisma/schema.prisma
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({});
// 関連の保存(パターン1)
const profile = Profile.create({ user });
// 関連の保存(パターン2)
user.profile = Profile.build({});
// 関連の保存(パターン3)
user.update({ profile: Profile.build({}) });
// 関連の取得
user.profile;
profile.user;
// 関連の削除(パターン1)
user.profile?.destroy();
// 関連の削除(パターン2)
user.profile = undefined;
// prisma/schema.prisma
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({});
// 関連の保存(パターン1)
const post = Post.create({ user });
// 関連の保存(パターン2)
user.posts.push(Post.build({}));
// 関連の保存(パターン3)
user.posts = [Post.build({})];
// 関連の取得
user.posts.toArray();
post.user;
// 関連の削除(パターン1)
user.posts.destroy(post);
// 関連の削除(パターン2)
post.destroy();
Prismaスキーマでは、明示的なMany-to-Manyリレーションと、非明示的なMany-to-Manyリレーションの2つの方法があります。
明示的なMany-to-Manyリレーションの場合、中間テーブルを定義します。 この場合は、前項のOne-to-Manyリレーションと同様に操作することになります。
以下では、非明示的なMany-to-Manyリレーションの例を示します。
// prisma/schema.prisma
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({});
// 関連の保存(パターン1)
user.groups.push(group);
// 関連の保存(パターン2)
user.groups = [group];
// 関連の保存(パターン3)
group.users.push(user);
// 関連の保存(パターン4)
group.users = [user];
// 関連の取得
user.groups.toArray();
group.users.toArray();
// 関連の削除(パターン1)
user.groups.destroy(group);
// 関連の削除(パターン2)
group.users.destroy(user);
モデルに対するクエリを行うためのインターフェースの利用例を示します。 各メソッドではモデル定義から生成された情報を利用し、型安全にクエリを行うことができます。 また、IDEの補完機能も利用することができます。
より詳細についてはRelationクラスのメソッド一覧を参照してください。
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();
モデルのクエリインターフェースでは、GROUP BY等の機能は現在サポートされていません。 これらのクエリではスキーマの型情報を利用するメリットが少ないためです。
モデルのクエリインターフェースでは実現できないクエリを実行する場合は、以下で説明する生SQLやKnexのQueryBuilderを使ったクエリ実行を利用してください。
Model.connection.execute()
メソッドを利用することで生のSQLクエリを実行し、同期的に結果を取得することができます。
import { Model } from "accel-record";
const rows = Model.connection.execute(
`select firstName, count(id) as cnt
from User
group by firstName`,
[]
);
console.log(rows);
// => [{ firstName: "John", cnt: 1 }, { firstName: "Alice", cnt: 2 }]
Knexを利用してのクエリの構築や実行ができます。
またKnexのQueryBuilderに execute()
メソッドを追加しており、これを利用すると同期的にクエリを実行することができます。
機能の詳細は以下のリンクを参照してください。
Knex Query Builder | Knex.js
import { Model } from "accel-record";
import { User } from "./models/index.js";
// Model.connection.knex で Knex のインスタンスを取得できます。
const knex = Model.connection.knex;
const rows = knex
.select("name", knex.raw("SUM(score) as total"))
.from("Score")
.groupBy("name")
.execute();
console.log(rows);
// => [{ name: "John", total: "1" }, { name: "Alice", total: "2" }]
// queryBuiler プロパティを利用して、各モデルに対応するテーブルへのクエリを行うことができます。
const rows = User.queryBuilder.select("name").groupBy("name").execute();
console.log(rows); // => [{ name: "John" }, { name: "Alice" }]
再利用可能なクエリの内容をスコープとして定義できます。
スコープを定義するには、モデルにstaticなクラスメソッドを用意し、 @scope
デコレータを付けます。
その後 prisma generate
を実行することで、スコープがクエリインターフェースに反映されます。
// src/models/user.ts
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 } });
}
}
上のような定義で、以下のようにスコープを利用することができます。
import { User } from "./models/index.js";
// name が John で、 age が 20以上 のユーザー数を取得
User.johns().adults().count(); // => 1
.search()
メソッドを使うと、オブジェクトベースの柔軟な検索が可能です。
(インターフェースは Ransack gem を参考にしています)
検索パラメータは、フィールド名と検索条件を組み合わせた文字列をキー、検索用の値をバリューとして持つオブジェクトで指定します。
キーには関連付けを含めることができます。
検索条件には、eq
, cont
, matches
, lt
, gte
, in
, null
などが利用可能です。
その他に not
, or
, and
, any
, all
などの修飾子も用意されています。
詳細はsearch()メソッドのドキュメントを参照してください。
import { User } from "./models/index.js";
const search = User.search({
name_eq: "John", // name が "John" に等しい
age_not_null: 1, // age が null でない
profile_bio_cont: "foo", // 関連である profile の bio が "foo" を含む
email_or_name_cont_any: ["bar", "baz"], // email または name が "bar" または "baz" を含む
});
const users = search.result();
また、検索パラメータのキーにはsearchableScopes
配列で定義された検索可能なスコープの名前を含めることができます。
例えば以下のように定義されたbio_cont
スコープは検索パラメータで使用することができます。
// src/models/user.ts
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" }); // profile の bio が "foo" を含む
const users = search.result();
Vitestを使ったテストでは、以下のようなsetupファイルを用意します。
// tests/vitest.setup.ts
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(), // schema.prismaファイルに基づいてtypeとprismaDirが自動的に設定されます。
// Vitestは通常マルチスレッドでテストが行われます。
// 各スレッドで異なるデータベースを利用するためには、VITEST_POOL_IDを利用してデータベースを分離します。
datasourceUrl: `mysql://root:@localhost:3306/accel_test${process.env.VITEST_POOL_ID}`,
});
// initAccelRecordでprismaDirを指定している場合、未反映のマイグレーションを実行することができます。
await Migration.migrate();
});
// beforeEach, afterEachでDatabaseCleanerを利用し、各テスト毎にデータベースをクリーンアップします。
beforeEach(async () => {
DatabaseCleaner.start();
});
afterEach(async () => {
DatabaseCleaner.clean();
});
// テスト終了時に同期処理用のサブプロセスを停止するために、afterAllでstopWorkerを呼び出します。
afterAll(async () => {
stopWorker();
});
Vitest設定ファイルのsetupFilesに上記のファイルを指定することで、テスト実行前にデータベースの初期化を行うことができます。
詳細はVitestのドキュメントを参照してください。
// vitest.config.js
export default {
test: {
globals: true,
setupFiles: ["./tests/vitest.setup.ts"], // ここに追加
// ...
},
// ...
};
テスト用のレコードを生成するために、Factoryを利用することができます。
詳細はaccel-record-factoryを参照してください。
// tests/factories/user.ts
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 };
// tests/user.test.ts
import { $User } from "./factories/user";
const newUser = $User.build();
newUser.firstName; // => "John"
newUser.lastName; // => "Doe"
newUser.age; // => 20
モデルに対するバリデーションのサンプルを記載します。
// src/models/user.ts
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(); // => false
user.errors.fullMessages(); // => ["FirstName can't be blank"]
user.firstName = "John";
user.isValid(); // => true
save, update, craeteメソッドを利用する場合、バリデーションが自動的に実行され、エラーが無い場合のみ保存処理が行われます。
import { User } from "./models/index.js";
// バリデーションエラーが発生した場合、saveやupdateはfalseを返します。
const newUser = User.build({ firstName: "" });
newUser.save(); // => false
newUser.errors.fullMessages(); // => ["FirstName can't be blank"]
const user = User.first()!;
user.update({ firstName: "" }); // => false
newUser.errors.fullMessages(); // => ["FirstName can't be blank"]
// バリデーションエラーが発生した場合、createでは例外がスローされます。
User.create({ firstName: "" }); // => Error: Failed to create
BaseModelの validateAttributes
メソッドをオーバーライドすることで、バリデーションを定義することができます。
// prisma/schema.prisma
model ValidateSample {
id Int @id @default(autoincrement())
accepted Boolean
pattern String
key String
count Int
size String
}
// ./models/validateSample.ts
import { Validator } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
export class ValidateSampleModel extends ApplicationRecord {
// validateAttributesメソッドをオーバーライドして、バリデーションを定義します。
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 });
// 独自のロジックでバリデーションを行う場合は、 errros.add メソッドを利用してエラーメッセージを追加します。
if (this.key && !/^[a-z]$/.test(this.key[0])) {
this.errors.add("key", "should start with a lowercase letter");
}
// カスタムバリデータの利用例
this.validatesWith(new MyValidator(this));
}
}
// カスタムバリデータは、Validatorを継承してvalidateメソッドを実装します。
class MyValidator extends Validator<{ key: string | undefined }> {
validate() {
if (this.record.key === "xs") {
this.errors.add("key", "should not be xs");
}
}
}
before
, after
デコレータを利用してモデルにコールバックを定義することで、バリデーションや保存の前後に処理を挟むことができます。
コールバックの対象は validation
, save
, create
, update
, destroy
となります。
(TypeScript 5.0 で実装された Stage 3 のデコレータが動作する環境で利用できます)
// ./models/callbackSample.ts
import { ApplicationRecord } from "./applicationRecord.js";
export class CallbackSampleModel extends ApplicationRecord {
@before("save")
beforeSave() {
// this method is called before save
}
@after("create")
afterCreate() {
// this method is called after create
}
}
toHash
メソッドやtoHashArray
メソッドを利用することで、モデルのデータをプレーンなオブジェクトに変換することができます。
import { User } from "./models/index.js";
const userHash = User.first()!.toHash({
only: ["firstName", "lastName"],
include: { posts: { only: ["title"] } },
});
console.log(userHash);
// => { firstName: "John", lastName: "Doe", posts: [{ title: "Hello" }] }
const usersHashArray = User.all().toHashArray({
only: ["firstName", "lastName"],
});
console.log(usersHashArray);
// => [{ firstName: "John", lastName: "Doe" }, { firstName: "Alice", lastName: "Smith" }]
toJson
メソッドを利用することで、モデルのデータをJSON文字列に変換することができます。
import { User } from "./models/index.js";
const userJson = User.first()!.toHah({
only: ["firstName", "lastName"],
include: { posts: { only: ["title"] } },
});
console.log(userJson);
// => {"firstName":"John","lastName":"Doe","posts":[{"title":"Hello"}]}
const usersJson = User.all().toHashArray({
only: ["firstName", "lastName"],
});
console.log(usersJson);
// => [{"firstName":"John","lastName":"Doe"},{"firstName":"Alice","lastName":"Smith"}]
Bulk Insertは、一度に複数のレコードをデータベースに挿入する機能です。
Accel Recordでは、import()
メソッドを利用して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",
});
Model.transaction()
メソッドでトランザクションを利用できます。Rollback
を例外として投げることでトランザクションをロールバックすることができ、トランザクションはネストすることができます。
import { Rollback } from "accel-record";
import { User } from "./models/index.js";
User.transaction(() => {
User.create({});
console.log(User.count()); // => 1
User.transaction(() => {
User.create({});
console.log(User.count()); // => 2
// Rollback の throw により内側のトランザクションはロールバックされます
throw new Rollback();
});
// 外側のトランザクションはコミットされます
});
console.log(User.count()); // => 1
lock()
やwithLock()
メソッドを利用することで行のロックを行うことができます。(MySQLと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" });
});
i18next
を利用した国際化機能を提供しています。
Model.model_name.human
メソッドとModel.human_attribute_name(attribute)
メソッドを使うことで、モデル名と属性名の翻訳を参照できます。
import i18next from "i18next";
import { User } from "./models/index.js";
i18next
.init({
lng: "ja",
resources: {
ja: {
translation: {
"accelrecord.models.User": "ユーザー",
"accelrecord.attributes.User.firstName": "名",
"accelrecord.attributes.User.lastName": "姓",
},
},
},
})
.then(() => {
console.log(User.modelName.human); // => "ユーザー"
console.log(User.humanAttributeName("firstName")); // => "名"
});
バリデーションエラーメッセージも翻訳に対応しており、以下からエラーメッセージを参照します。
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 });
}
}
上のUserModelの例では、'blank'
というメッセージキーの翻訳がエラーメッセージに利用されます。
この例では、以下のキーを順に探し、最初に見つかったキーが利用されます。
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: "ja",
resources: {
ja: {
translation: {
"accelrecord.models.User": "ユーザー",
"accelrecord.attributes.User.firstName": "名",
"accelrecord.attributes.User.lastName": "姓",
"accelrecord.errors.messages.blank": "を入力してください", // 追加
},
},
},
})
.then(() => {
const user = User.build({});
user.validate();
console.log(User.errors.fullMessages);
// => ["名 を入力してください"]
});
各バリデーションに対応するメッセージキーは以下の通りです。
バリデーション | オプション | メッセージキー | 式展開 |
---|---|---|---|
acceptance | - | 'accepted' | - |
presence | - | 'blank' | - |
length | 'minimum' | 'tooShort' | count |
length | 'maximum' | 'tooLong' | count |
uniqueness | - | 'taken' | - |
format | - | 'invalid' | - |
inclusion | - | 'inclusion' | - |
numericality | 'equalTo' | 'equalTo' | count |
式展開が count
になっているものは、エラーメッセージに {{count}}
を含むときにその部分がオプションで指定された値に置き換えられます。
Enumの各値に対しても翻訳を定義することができます。
// prisma/schema.prisma
enum Role {
MEMBER
ADMIN
}
model User {
/* ... */
role Role @default(MEMBER)
}
User.role.options()
で、Enumの各値に対応する翻訳を取得することができます。
各User
が持つrole
に対して、roleText
というプロパティでEnumの値に対応する翻訳を取得することができます。
import i18next from "i18next";
import { User } from "./models/index.js";
i18next
.init({
lng: "ja",
resources: {
ja: {
translation: {
"enums.User.Role.MEMBER": "メンバー",
"enums.User.Role.ADMIN": "管理者",
},
},
},
})
.then(() => {
User.role.options(); // => [["メンバー", "MEMBER"], ["管理者", "ADMIN"]]
const user = User.build({});
user.role; // => "MEMBER"
user.roleText; // => "メンバー"
});
user.role
の例では、以下のキーを順に探し、最初に見つかったキーが利用されます。
enums.User.Role.MEMBER
enums.defaults.Role.MEMBER
enums.Role.MEMBER
Bcryptを利用してセキュアにハッシュ化したパスワードを保持し、それを用いて認証するための仕組みを提供しています。
まずモデルにハッシュ化されたパスワードを保持するためpasswordDigest
フィールドを追加します。
// prisma/schema.prisma
model User {
...
passwordDigest String // パスワードのハッシュ値を保持する
}
次にhasSecurePassword()
を利用してパスワードのハッシュ化と認証を行うための機能をモデルに対して追加します。
// ./models/user.ts
import { hasSecurePassword, Mix } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends Mix(ApplicationRecord, hasSecurePassword()) {}
これでpassword
やpasswordConfirmation
フィールドを利用してパスワードのバリデーションとハッシュ化を、authenticate()
メソッドを利用してパスワードの認証を行うことができます。
import { User } from "./models/index.js";
const user = User.build({});
user.password = "";
user.save(); // => false (password can't be blank)
user.password = "myPassword";
user.save(); // => false (password confirmation doesn't match)
user.passwordConfirmation = "myPassword";
user.save(); // => true
user.authenticate("invalid"); // => false
user.authenticate("myPassword"); // => true
パスワード保持用のフィールド名をpasswordDigest
以外に設定したり、複数のパスワードをモデルで管理することもできます。
// ./models/user.ts
import { hasSecurePassword, Mix } from "accel-record";
import { ApplicationRecord } from "./applicationRecord.js";
export class UserModel extends Mix(
ApplicationRecord,
hasSecurePassword(), // passwordDigest フィールドを利用
hasSecurePassword({ attribute: "recovery", validation: false }) // recoveryDigest フィールドを利用
) {}
Formオブジェクトは、通常のモデルとは切り分けてバリデーションや保存処理などを行えるデザインパターンです。複数のモデルにまたがる処理や、通常のモデルと対応しないようなフォームの処理を行うために利用されます。
FormModel
クラスを継承することで、テーブルとは無関係なクラスでありながら通常のモデルと同様にattributeを持たせた上でバリデーションも行うことができます。
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な値について、TypeScriptではJavaScriptと同様にundefinedとnullの2つが存在します。
Accel Recordに関してはnullを利用する必要は無く、Nullableな値の表現はundefinedに統一して扱えるように設計しています。
これは主にnullとundefinedの混在による複雑さを避けるためです。
undefinedとnullを使い分けるメリットもあるとは理解しますが、それよりも型の複雑さを避けることでコードの可読性や保守性が保たれることを重視しています。
import { User } from "./models/index.js";
// オプショナルなフィールドのデフォルト値はundefinedとなります。
const newUser = User.build({});
newUser.age; // => undefined
// DB上でnullを持つレコードの検索にも、undefinedを指定することができます。
const user = User.findBy({ age: undefined })!;
// DB上でnullの値を持つフィールドはundefinedとして扱われます。
user.age; // => undefined
// オプショナルなフィールドにundefinedを指定することで、DB上の値をnullで更新することができます。
user.update({ age: undefined });
Accel Recordの設計や開発の動機などに関する記事を紹介します。