模型
Sutando 模型基于活动记录(Active Record)模式:每张数据表都对应一个模型类,用于读写记录、定义属性并维护关联关系。
除了数据库查询构建器,Sutando 还拥有构建在 活动记录模式 (Active Record) 之上的数据模型。
Sutando 的数据模型层使执行 CRUD 操作、管理模型之间的关系和定义生命周期挂钩变得超级容易。
我们建议广泛使用模型,并针对特定用例使用标准查询构建器。
创建你的第一个模型
我们来看一个基本的模型示例,随后开始讨论 Eloquent 的一些关键约定。
import { Model } from 'sutando';
class Flight extends Model {
//
}
数据表名称
看过上面的示例,你可能留意到了我们没有为 Sutando 指明 Flight 模型要使用哪张数据表。除非明确指定使用其它数据表,否则将按照约定,使用类的复数形式「蛇形命名」来作为表名。因此,在这种情况下,Sutando 将认为 Flight 模型存储的是 flights 表中的数据,而 AirTrafficController 模型会将记录存储在 air_traffic_controllers 表中。
如果模型的相应数据库表不符合此约定,可以通过在模型上定义 table 属性并指定模型的表名:
import { Model } from 'sutando';
class Flight extends Model {
// 该表将与模型关联
table = 'my_flights';
}
主键
Sutando 将假设模型有一个默认的主键列,该列为 id 。如果有必要,你可以定义一个字段 primaryKey,用来指定为模型的主键。
import { Model } from 'sutando';
class Flight extends Model {
// 与表关联的主键
primaryKey = 'flight_id';
}
此外,Sutando 默认有一个 int 值的主键,如果你的主键不是自增或者不是数字类型,你可以在你的模型上定义一个属性 incrementing ,并将其设置为 false:
class Flight extends Model {
// 指明模型的ID是否自动递增
incrementing = false;
}
如果你的模型主键不是 int,应该定义一个 keyType 属性在模型上,其值应为 string:
class Flight extends Model {
// ID的数据类型
keyType = 'string';
}
UUID 主键 / 字符串主键
你可以选择使用字符串,而不是使用自动递增的整数作为模型的主键。例如使用 UUID 作为主键, 通过在模型中定义一个 newUniqueId 方法:
$ npm install uuid --save$ yarn add uuid$ pnpm add uuid
import { Model, compose, HasUniqueIds } from 'sutando';
import uuid from 'uuid';
class Article extends compose(Model, HasUniqueIds) {
newUniqueId() {
return uuid.v4();
}
// ...
}
const article = await Article.create({
title: 'Traveling to Europe'
});
article.id; // "8f8e8478-9035-4d23-b9a7-62f4d2612ce5"
时间戳(Timestamps)
默认情况下,Sutando 希望模型相应的数据库表中存在 created_at 和 updated_at 列。在创建或更新模型时,Sutando 将自动设置这些列的值。如果不希望这些列由 Sutando 自动管理,那么你应该在模型上定义一个 timestamps 属性并且值为 false:
import { Model } from 'sutando';
class Flight extends Model {
// 是否主动维护时间戳
timestamps = false;
}
如果你需要自定义存储时间戳的字段名,可以在模型中设置 CREATED_AT 和 UPDATED_AT 常量的值来实现:
import { Model } from 'sutando';
class Flight extends Model {
static CREATED_AT = 'creation_date';
static UPDATED_AT = 'updated_date';
}
数据库连接
默认情况下,Sutando 模型将使用你的应用程序配置的默认数据库连接。如果你将要指定使用特殊的数据库链接在你的模型,你可以设置一个 connection 属性在你的模型:
import { Model } from 'sutando';
class Flight extends Model {
connection = 'sqlite';
}
默认属性值
默认情况下,被实例化的模型不会包含任何属性值。如果你想为模型的某些属性定义默认值,可以在模型上定义一个 attributes 属性。放在 attributes 中的属性值应该是原始的,“可存储的” 格式,就像它们刚刚从数据库中读取一样:
import { Model } from 'sutando';
class Flight extends Model {
attributes = {
options: '[]',
delayed: false,
};
}
声明字段
Sutando 不要求你定义模型的字段。属性会在运行时从数据库中读取,因此模型实例上可以访问任何列。不过在使用 TypeScript 时,你可以使用 declare 关键字声明这些字段,让编译器知道它们的类型:
import { Model } from 'sutando';
class Flight extends Model {
declare id: number;
declare name: string;
declare destination: string;
declare delayed: boolean;
declare created_at: Date;
declare updated_at: Date;
}
declare 只影响类型:它不会生成任何 JavaScript 代码,也不会改变属性从数据库读取或写入数据库的方式。声明字段是可选的;未声明的字段仍然可以访问,但类型为 any。更多细节请参阅 TypeScript 支持。
以编程方式创建模型
除了编写模型类之外,你还可以在运行时通过 createModel 方法创建模型。它接受一个名称和一个选项对象,返回的模型类与你自己定义的模型类行为完全一致:
import { sutando } from 'sutando';
const Flight = sutando.createModel('Flight', {
table: 'flights',
primaryKey: 'id',
keyType: 'int',
incrementing: true,
timestamps: true,
connection: null,
with: [],
casts: { delayed: 'boolean' },
});
const flight = await Flight.query().create({ name: 'London to Paris' });
这些选项与上面描述的模型属性一一对应:table、primaryKey、keyType、incrementing、timestamps、connection(模型使用的连接名称)、with(始终预加载的关联)以及 casts。
另外一些选项覆盖了通常需要编写代码的模型部分:
plugins:应用到模型的插件数组,例如[SoftDeletes]。attributes:访问器与修改器,每一个都用Attribute.make({ get, set })创建。relations:关联,每一个都是接收模型实例并返回关联的函数。scopes:查询作用域,每一个都是接收查询构造器的函数。
import { sutando, Attribute, compose, SoftDeletes } from 'sutando';
const Post = sutando.createModel('Post', {
table: 'posts',
plugins: [SoftDeletes],
attributes: {
slug: Attribute.make({ get: () => 'my-slug' }),
},
scopes: {
published: (query) => query.where('published', true),
},
relations: {
comments: (model) => model.hasMany(Comment),
},
});
隔离实例
你导入的 sutando 对象是唯一的共享实例:sutando.addConnection() 会向它添加连接,所有模型默认都使用它。当你需要一套完全独立的连接——另一个数据库、每个租户各自的数据库,或者测试数据库——可以使用 new sutando() 创建一个隔离实例:
import { sutando } from 'sutando';
const tenant = new sutando();
tenant.addConnection({
client: 'sqlite3',
connection: { filename: './tenant.sqlite' },
});
const User = tenant.createModel('User', { table: 'users' });
await User.query().create({ name: 'Alice' });
隔离实例拥有自己的连接、自己的连接池和自己的模型注册表,因此不会影响全局实例。由它创建的模型会绑定到该实例,始终查询它,即使全局实例上存在同名表的模型。
隔离实例提供与全局实例相同的 API:addConnection、connection、table、schema、transaction、beginTransaction、createModel 和 destroyAll。
模型检索
创建模型和它关联的数据库表后,你就可以从数据库中查询数据了。你可以将每个 Sutando 模型视为一个强大的 查询构造器,使你能够更快速地查询与该模型关联的数据库表。模型的 all 方法将从模型的关联数据库表中检索所有记录:
import { Flight } from './models';
const flights = await Flight.query().all();
flights.map(flight => {
console.log(flight.name)
})
附加约束
Sutando 的 all 方法会返回模型中所有的结果。由于每个 Sutando 模型都充当一个 查询构造器 ,所以你也可以添加查询条件,然后使用 get/first/find 方法获取查询结果:
const flights = await Flight.query().where('active', 1)
.orderBy('name')
.take(10)
.get();
const flight = await Flight.query().where('active', 1).first();
const flight = await Flight.query().find(5);
重新加载模型
你可以使用 fresh 和 refresh 重新加载从数据库中检索的 Sutando 模型实例。fresh 方法会重新从数据库中检索模型。现有的模型实例不受影响:
const flight = await Flight.query().where('number', 'FR 900').first();
const freshFlight = await flight.fresh();
refresh 方法会使用数据库中的新数据重新赋值现有的模型。此外,已经加载的关系也会被重新加载:
const flight = await Flight.query().where('number', 'FR 900').first();
flight.number = 'FR 456';
await flight.refresh();
flight.number; // "FR 900"
集合
Sutando 的 all 和 get 会从数据库中取得多个结果。然而,这些方法返回的不是数组,而是一个 Collection 实例。
Sutando 的 Collection 继承自 collect.js,它提供了大量的辅助函数来与数据集交互。例如,reject 方法可以根据闭包中的结果从集合中删除模型:
const flights = await Flight.query().where('destination', 'Paris').get();
const newFlights = flights.reject(flight => {
return flight.cancelled;
});
除了 collect.js 提供的函数外,Sutando 集合还提供了一些 额外的函数,这些函数专用于与 Sutando 模型集合进行交互。
由于 Sutando 的集合实现了可迭代接口,因此你可以像循环数组一样循环集合:
for (let flight of flights) {
console.log(flight.name);
}
结果分块
如果您想要尝试使用 all 或者 get 方法来加载成千上万的 Sutando 模型数据,那么您的应用程序可能会耗尽内存。为了避免出现这种情况,可以使用 chunk 方法来处理这些模型数据。
chunk 方法将会传递模型子集给一个闭包来进行处理。由于每次只获取 Sutando 模型当前块的数据,所以当处理大量模型数据的时候,chunk 方法将会明显减少内存的使用量:
import { Flight } from './models';
await Flight.query().chunk(200, flights => {
flights.map(flight => {
//
});
});
检索单个模型 / 聚合
除了检索与给定查询匹配的所有记录之外,您还可以使用 find 或 first 方法检索单个记录。 这些方法不返回模型集合,而是返回单个模型实例:
import { Flight } from './modles';
// 使用主键检索模型...
const flight = await Flight.query().find(1);
// 检索符合查询条件的第一个模型...
const flight = await Flight.query().where('active', 1).first();
未找到异常
有时,如果找不到模型,您可能希望抛出异常。 这在路由或控制器中特别有用。 findOrFail 和 firstOrFail 方法将检索查询的第一个结果; 但是,如果没有找到结果,将会抛出一个 ModelNotFoundError:
import { ModelNotFoundError } from 'sutando';
try {
const flight = await Flight.query().findOrFail(1);
const flight = await Flight.query().where('legs', '>', 3).firstOrFail();
} catch (e) {
e instanceof ModelNotFoundError;
}
在框架的错误处理中捕获 ModelNotFoundError,就可以自动把 404 HTTP 响应返回给客户端:
import { Hono } from 'hono';
import { ModelNotFoundError } from 'sutando';
const app = new Hono();
app.get('/users/:id', async (c) => {
const user = await User.query().findOrFail(c.req.param('id'));
return c.json(user);
});
app.onError((err, c) => {
if (err instanceof ModelNotFoundError) {
return c.text(err.message, 404);
}
throw err;
});
检索或创建模型
firstOrCreate 方法将尝试使用给定的键值对定位数据库记录。 如果在数据库中找不到模型,则会插入一条记录,其中包含将第一个数组参数与可选的第二个数组参数合并后的属性:
firstOrNew 方法与 firstOrCreate 一样,将尝试在数据库中查找与给定属性匹配的记录。 但是,如果找不到模型,则会返回一个新的模型实例。 请注意,firstOrNew 返回的模型尚未持久化到数据库中。 你需要手动调用 save 方法来持久化它:
import { Flight } from './modles';
// 按名称检索航班或在它不存在时创建它...
const flight = await Flight.query().firstOrCreate({
name: 'London to Paris'
});
// 按名称检索航班或使用名称、延迟和到达时间属性创建它...
const flight = await Flight.query().firstOrCreate(
{ name: 'London to Paris' },
{ delayed: 1, arrival_time: '11:30' }
);
// 按名称检索航班或实例化新的航班实例...
const flight = await Flight.query().firstOrNew({
name: 'London to Paris'
});
// 按名称检索航班或使用名称、延迟和到达时间属性实例化...
const flight = await Flight.query().firstOrNew(
{ name: 'Tokyo to Sydney' },
{ delayed: 1, arrival_time: '11:30' }
);
聚合查询
在与 Sutando 模型交互时,您还可以使用 查询构建器 提供的 count、sum、max 和其他 聚合方法。 正如你所料,这些方法返回一个数值而不是一个 Sutando 模型实例:
const count = await Flight.query().where('active', 1).count(); // 100
const max = await Flight.query().where('active', 1).max('price'); // 104
const flight = await Flight.query().find(1); // flight instanceof Flight
插入及更新模型
插入
在使用 Sutando 时,我们不仅仅需要从数据库中检索模型。 我们还需要插入新记录。 幸运的是,Sutando 让它变得非常简单。 要将新记录插入数据库,您应该实例化一个新模型实例并在模型上设置属性。 然后,在模型实例上调用 save 方法即可:
// hono
import { Flight } from './model';
app.post('/flights', async (c) => {
// 验证请求...
const data = await c.req.json();
const flight = new Flight;
flight.name = data.name;
await flight.save();
return c.json(flight);
});
在此示例中,我们将传入 HTTP 请求中的 name 字段分配给 Flight 模型实例的 name 属性。 当我们调用 save 方法时,一条记录将被插入到数据库中。 模型的 created_at 和 updated_at 时间戳会在调用 save 方法时自动设置,因此无需手动设置。
或者,您可以使用 create 方法“保存” 新模型。 create 方法将返回新插入的模型实例:
import { Flight } from './model';
const flight = await Flight.query().create({
name: 'London to Paris',
});
更新
save 方法也可以用来更新数据库中已经存在的模型。 要更新模型,您应该检索它并设置您希望更新的任何属性。 然后调用模型的 save 方法。 同样,updated_at 时间戳会自动更新,因此无需手动设置其值:
import { Flight } from './model';
const flight = await Flight.query().find(1);
flight.name = 'Paris to London';
await flight.save();
批量更新
还可以批量更新与给定条件匹配的所有模型。 在此示例中,所有 active 且 destination 为 San Diego 的航班都将被标记为延迟:
await Flight.query().where('active', 1)
.where('destination', 'San Diego')
.update({
delayed: 1,
});
update 方法需要一个表示应该更新的列的列和值对数组。 update 方法返回受影响的行数。
检查属性变更
Sutando 提供了 isDirty 方法,以检查模型的内部状态并确定其属性从最初加载时如何变化。
isDirty 方法确定自加载模型以来是否已更改任何属性。 您可以传递特定的属性名称来确定特定的属性是否变脏:
import { Flight } from './model';
const user = await User.query().create({
first_name: 'Taylor',
last_name: 'Otwell',
title: 'Developer',
});
user.title = 'Painter';
user.isDirty(); // true
user.isDirty('title'); // true
user.isDirty('first_name'); // false
user.isDirty(['first_name', 'title']); // true
await user.save();
user.isDirty(); // false
新增或更新
有时,如果不存在匹配模型,您可能需要更新现有模型或创建新模型。 与 firstOrCreate 方法一样,updateOrCreate 方法将模型持久化,因此无需手动调用 save 方法。
在下面的示例中,如果存在具有 Oakland 的 departure 位置和 San Diego 的 destination 位置的航班,其 price 和 discounted 列将被更新。 如果不存在这样的航班,将创建一个新航班,该航班具有将第一个参数数组与第二个参数数组合并后的属性:
const flight = await Flight.query().updateOrCreate(
{
departure: 'Oakland',
destination: 'San Diego'
},
{
price: 99,
discounted: 1
}
);
删除模型
想删除模型,你可以调用模型实例的 delete 方法:
import { Flight } from './models';
const flight = await Flight.query().find(1);
await flight.delete();
通过其主键删除现有模型
上面的示例中,我们先检索,再用 delete 删除。若知道主键,则用 destroy 直接删除。 destroy 可以接受一个主键、多个主键、一个主键数组或一个主键集合 collection:
await Flight.query().destroy(1);
await Flight.query().destroy(1, 2, 3);
await Flight.query().destroy([1, 2, 3]);
使用查询删除模型
通过 Sutando 查询来删除所有符合查询条件的模型。 例如,我们将删除所有标记为无效的航班。像批量更新一样,批量删除将不会为已删除的模型调度模型事件:
const deleted = await Flight.query().where('active', 0).delete();
软删除
除了实际删除记录,还可以 “软删除”。 软删除不会从数据库中删除,而是在 deleted_at 属性中设置删除模型的日期和时间。要启用软删除,请在使用 SoftDeletes 插件和在相应数据表中添加 deleted_at 字段:
import { Model, compose, SoftDeletes } from 'sutando';
class Flight extends compose(Model, SoftDeletes) {
// ...
}
那现在,当你在模型实例上使用 delete 方法,当前日期时间会写入 deleted_at 字段。同时,查询出来的结果也会自动排除已被软删除的记录。
你可以使用 trashed 方法来验证给定的模型实例是否已被软删除:
if (flight.trashed()) {
//
}
恢复软删除模型
有时会对软删除模型进行「撤销」,在已软删除的数据上使用 restore 方法即可恢复到有效状态。 restore 方法会将模型的 deleted_at 列设为 null:
await flight.restore();
你也可以在查询中使用 restore 方法,从而快速恢复多个模型。和其他「批量」操作一样,这个操作不会触发模型的任何事件:
await Flight.query().withTrashed()
.where('airline_id', 1)
.restore();
类似 withTrashed 方法,restore 方法也可以用在 关联 上:
await flight.related('history').restore();
永久删除
有时你可能需要从数据库中真正删除模型。要从数据库中永久删除软删除的模型,请使用 forceDelete 方法:
await flight.forceDelete();
您也可以在模型关联上调用 forceDelete 方法:
await flight.related('history').forceDelete();
查询软删除模型
包含已软删除的模型
前面提到,查询结果会自动剔除已被软删除的结果。当然,你可以使用 withTrashed 方法来获取包括软删除模型在内的模型:
import { Flight } from './models';
const flights = await Flight.query().withTrashed()
.where('account_id', 1)
.get();
withTrashed 方法也可以用在 关联 查询:
await flight.related('history').withTrashed().get();
只检索软删除模型
onlyTrashed 方法只获取已软删除的模型:
const flights = await Flight.query().onlyTrashed()
.where('airline_id', 1)
.get();
查询作用域
作用域允许定义通用的约束集合以便在应用程序中重复使用。例如,你可能经常需要获取所有「流行」的用户。要定义这样一个范围,只需要在对应的 Sutando 模型方法前添加 scope 前缀。
作用域总是返回一个查询构造器实例:
import { Model } from './models';
class User extends Model {
scopePopular(query){
return query.where('votes', '>', 100);
}
scopeActive(query){
query.where('active', 1);
}
}
使用作用域
一旦定义了作用域,就可以在查询该模型时调用作用域方法。不过,在调用这些方法时不必包含 scope 前缀。甚至可以链式调用多个作用域,例如:
import { User } from './models';
const users = await User.query().popular().active().orderBy('created_at').get();
通过 or 查询运算符组合多个 Sutando 模型作用域可能需要使用闭包来实现正确的逻辑分组:
const users = await User.query().popular().orWhere(query => {
query.active();
}).get();
动态作用域
有时可能地希望定义一个可以接受参数的作用域。把额外参数传递给作用域就可以达到此目的。作用域参数要放在 query 参数之后:
import { Model } from './models';
class User extends Model {
scopeOfType(query, type){
return query.where('type', type);
}
}
一旦将预期的参数添加到作用域方法的签名中,您就可以在调用作用域时传递参数:
const users = await User.query().ofType('admin').get();
模型比较
有时可能需要判断两个模型是否「相同」。is 和 isNot 方法可以用来快速校验两个模型是否拥有相同的主键、表和数据库连接:
if (post.is(anotherPost)) {
//
}
if (post.isNot(anotherPost)) {
//
}