基础

本文主要向大家介绍 Leoric 基础概念。读完本文后,你将了解如下内容:

目录

  1. Leoric 是什么
  2. 约定大于配置
    1. 命名约定
    2. 表结构约定
  3. 编写数据模型
  4. 覆盖命名约定
  5. 连接数据模型和数据库
  6. 读取与写入数据
    1. 创建
    2. 读取
    3. 更新
    4. 删除
  7. 属性方法
    1. attribute()
    2. getter / setter
  8. 脏检查
    1. changed()
    2. changes()
    3. previousChanged()
    4. previousChanges()
  9. 数据校验
  10. 钩子
  11. 迁移任务

Leoric 是什么

Leoric 是 Node.js 与关系型数据库之间的一层对象关系映射模型。它可以被用作 MVC 中的 M - 即 MVC 体系中负责表现业务数据与逻辑的那一层。

对象关系映射(简称 ORM)是应用程序中的对象与关系型数据管理系统中的表相互联系的一种方式。在许多编程语言中都有流行 ORM 的概念,例如 Ruby 的 Active Record、Python 的 SQLAlchemy、以及 Java 的 Hibernate。Leoric 受 Active Record 影响颇多,或许你从 Leoric 的文档组织方式已经看出一二。

Leoric 应当具备的能力是:

约定大于配置

一般而言,配置比约定要更加一目了然,也更经常被使用。但约定风格也有它的优势,如果你的表结构风格遵循 Leoric 的约定,几乎不需要写多少配置就可以编写数据模型。

命名约定

默认情况下,Leoric 会按一定规则寻找数据模型与数据库表的对应关系。详细规则如下:

以下为一些转换示例:

数据模型
Shop shops
TagMap tagMaps
Mouse mice
Person people

Leoric 使用 pluralize 转换单复数。如果你觉得这些转换规则不直观(对非英语母语的人来说很正常),也可以明确配置数据模型对应的表名称、或者重命名数据模型的属性。我们将在“覆盖命名约定”一文深入讨论。

表结构约定

Leoric 提供三个配置关联关系的静态方法 .hasMany().hasOne()、以及 .belongsTo()。用于关联的主键、外键约定如下:

还有一些可选的字段名,可为数据模型增加额外特性:

字段名 属性名 描述
created_at createdAt 在数据记录被创建时自动更新
updated_at updatedAt 在数据记录被更新时自动更新
deleted_at deletedAt 在数据记录被伪删除时自动更新

TDDL 使用的时间戳字段会被自动映射。gmt_create 映射为 createdAtgmt_modified 映射为 updatedAt、以及 gmt_deleted(如果存在)会被映射为 deletedAt

调用数据模型的 Model.remove({...}) 方法时,如果存在deletedAt 属性,Leoric 将更新待删除记录的 deletedAt 属性,而不是将这些记录从数据库中永久删除。可以改为调用 Model.remove({...}, true) 方法来执行永久删除。

编写数据模型

假设 shops 表结构如下:

CREATE TABLE shops (
  id int(11) NOT NULL auto_increment,
  name varchar(255),
  PRIMARY KEY (id)
);

Shop 数据模型需要继承 Leoric 输出的 Bone 基类:

const { Bone } = require('leoric')
class Shop extends Bone {}

如果不希望使用第三方工具专门管理表结构,也可以直接让 Leoric 来完成这部分工作。在数据模型中声明模型的属性名,使用数据模型前同步到数据库即可:

const { Bone, Realm } = require('leoric');
const { BIGINT, STRING } = Bone.DataTypes;

// define Shop
class Shop extends Bone {
  static attributes = {
    id: { type: BIGINT, primaryKey: true },
    name: STRING,
  }
}

// connecting Shop to shops table in database
const realm = new Realm({ host: 'localhost', models: [ Shop ] });
// synchronize model attributes to table
await realm.sync();

如果你的项目使用 TypeScript 编写,也可以使用装饰器来声明模型:

import { Bone, Realm } from 'leoric';
const { BIGINT, STRING } = Bone.DataTypes;

// define Shop
class Shop extends Bone {
  // 主键声明也可以省略,默认按照如下方式声明
  @Column({ primaryKey: true })
  id: bigint;

  // 字符串默认按照 VARCHAR(255) 类型定义
  @Column()
  name: string;
}

然后就可以用 Shop 数据模型操作数据了:

const shop = new Shop({ name: 'Horadric Cube' })
await shop.save()
// 或者
await Shop.create({ name: 'Horadric Cube' })

覆盖命名约定

绝大部分命名约定都有对应的覆盖方法,我们可以使用 static table 覆盖表名:

class Shop extends Bone {
  static table = 'stores'
}

还可以使用 static primaryKey 指定主键名:

class Shop extends Bone {
  static primaryKey = 'shopId'
}

以及使用 static attributes 自定义数据模型属性对应的字段名:

class Shop extends Bone {
  static attributes = {
    deletedAt: { type: DATE, columnName: 'removed_at' },
  }
}

如果数据模型的属性信息不在模型中直接维护,也可以等数据模型信息从数据库加载后,在 static initialize() 方法中重命名属性名:

class Shop extends Bone {
  static initialize() {
    this.renameAttribute('removedAt', 'deletedAt')
  }
}

还可以在 static initialize() 中配置模型的关联关系,具体方法会在之后详细讨论。TypeScript 项目一般不需要通过这个静态方法,相关配置都有提供对应的装饰器版本,上述示例对应的 TypeScript 声明方式为:

class Shop extends Bone {
  @Column({ name: 'removed_at' })
  deltedAt: Date;
}

连接数据模型和数据库

数据模型需要和数据库连接方可使用,推荐使用如下方式:

const Realm = require('leoric');
const realm = new Realm({
  dialect: 'mysql',
  host: 'localhost',
  models: '/path/to/models',
});
await realm.sync();

realm.sync() 会根据数据模型中定义的字段信息 Model.attributes 自动执行表结构变更,确保数据库中的表结构和数据模型中的一致。在应用数据比较大或者应用表结构变更比较频繁且剧烈的情况下,一般不推荐使用。

后者这种情况比较适合仅连接数据库,使用 Leoric 的表结构变更功能来手动维护数据库中的表结构:

const Realm = require('leoric');
const realm = new Realm(...);
await realm.connect();

v0.x 版本过来的用户,仍然可以选择使用 connect() 直接连接数据库:

const { connect } = require('leoric');

// 连接数据模型到数据库
await connect({
  host: 'example.com',
  port: 3306,
  user: 'john',
  password: 'inputYourCodeHere',
  db: 'tmall',
  models: [Shop]
});

// 直接传入数据模型所在的路径
await connect({ ...opts, path: '/path/to/models' });

当然,如果是使用 Egg 开发 Web 应用,更加推荐直接用 egg-orm 插件。

读取与写入数据

数据模型声明、连接后,我们可以:

// 插入一条店铺记录
await Shop.create({ name: 'Barracks' });

// 查找店铺记录并更新
const shop = await Shop.findOne({ name: 'Barracks' });
shop.name = 'Horadric Cube';
await shop.save();

// 移除记录
await Shop.remove({ name: 'Horadric Cube' });

创建

有两种插入数据库的方式。我们可以使用 Model.create()

const shop = await Shop.create({ name: 'Barracks', credit: 10000 })

或者先创建一个实例,更新属性,最后再使用 model.save()

const shop = new Shop({ name: 'Barracks' })
shop.credit = 10000
await shop.save()

两者对应的 SQL 都是:

INSERT INTO shops (name, credit, type) VALUES ('Barracks', 1000);

读取

尽管 Leoric 提供的查询方法花样繁多,最常用的还是 Model.find() and Model.findOne()

// 读取所有店铺
Shop.find()
// 或者
Shop.all
// => SELECT * FROM shops;

// 读取一家店铺
Shop.findOne()
// => SELECT * FROM shops LIMIT 1;

// 查找一家名为 Deckard Cain 的店铺
Shop.findOne({ name: 'Deckard Cain' })
// => SELECT * FROM shops WHERE name = 'Deckard Cain' LIMIT 1;

// 找到所有信用分高于 1000 的店铺
Shop.where('credit > 1000')
// => SELECT * FROM shops WHERE credit > 1000;

有关读取数据库的详细说明,参考查询接口一文。

更新

和插入数据一样,有两种更新数据的方式。如果数据模型对象已经读取在手,我们可以更新它们的属性值,再使用model.save() 持久化数据:

const shop = await Shop.findOne({ name: 'Barracks' })
// => Shop { id: 1, name: 'Barracks' }
shop.credit = 10000
await shop.save()

上例对应的 SQL 如下:

UPDATE shops SET credit = 10000 WHERE id = 1;

如果想要节省反复读取、更新带来的数据库开销,我们也可以使用 Model.update() 一步到位:

await Shop.update({ name: 'Barracks' }, { credit: 10000 })

上例对应的 SQL 如下:

UPDATE shops SET credit = 10000 WHERE name = 'Barracks';

删除

同样的,实例方法 model.remove() 和静态方法 Model.remove() 均可用来从数据库删除数据。例如:

const shop = await Shop.find({ name: 'Barracks' })
// => Shop { id: 1, name: 'Barracks' }
await shop.remove(true)
// DELETE FROM shops WHERE id = 1

await Shop.remove({ name: 'Barracks' }, true)
// DELETE FROM shops WHERE name = 'Barracks'

你可能会奇怪这里额外传递的 true 参数是做什么用的。这是因为默认情况下 Leoric 会执行伪删除,仅更新 deleteAt 属性,而不是真的把数据从数据库删除。数据模型必须包含 deleteAt 属性,用来记录删除时间。

所以如果 Shop 数据模型有 deletedAt 属性:

const shop = await Shop.find({ name: 'Barracks' })
// => Shop { id: 1, name: 'Barracks' }
await shop.remove()
// UPDATE shops SET deleted_at = NOW() WHERE id = 1

await Shop.remove({ name: 'Barracks' })
// UPDATE shops SET deleted_at = NOW() WHERE name = 'Barracks'

如果 Shop 数据模型没有 deletedAt 属性,而 model.remove()Model.remove() 方法并没有传递 true,Leoric 将抛出异常。

属性方法

attribute()

默认提供 model.attribute(name)model.attribute(name, value) 方法来读写属性:

const shop = new Shop({ name: 'FamilyMart' });
assert.equal(shop.attribute('name'), 'FamilyMart');
shop.attribute('name', '7-Eleven');
assert.equal(shop.attribute('name'), '7-Eleven');

对于模型属性,我们还默认提供 getter 和 setter,因此一般不需要用到 model.attribute() 方法。需要覆盖属性的 getter 或者 setter,这个方法就派上用场了:

class Shop extends Bone {
  set name(value) {
    if ([ 'FamilyMart', '7-Eleven' ].includes(value)) {
      this.attribute('name', value);
    }
    throw new Error(`unknown shop name: ${value}`);
  }
}

应当避免在属性的 getter 或者 setter 中调用其他属性的 getter 或者 setter 方法,容易出现循环调用的情况,可以一律使用 model.attribute() 方法来替代。

getter / setter

默认情况下,我们会给模型属性生成对应的 getter / setter,例如:

class Shop extends Bone {
  static attributes = {
    name: STRING,
    createdAt: DATE,
    updatedAt: DATE,
  }
}

使用这个模型时,可以直接读取或者设置 name

const shop = new Shop({ name: 'FamilyMart' });
assert.equal(shop.name, 'FamilyMart');
shop.name = '7-Eleven';
assert.equal(shop.name, '7-Eleven');

可以只覆盖属性的 getter / setter 中的一个,模型会在初始化的时候自动补上剩余的部分,例如:

class Shop extends Bone {
  static attributes = {
    name: STRING,
    createdAt: DATE,
    updatedAt: DATE,
  }
  set name(value) {
    if ([ 'FamilyMart', '7-Eleven' ].includes(value)) {
      this.attribute('name', value);
    }
    throw new Error(`unknown shop name: ${value}`);
  }
}

使用装饰器的版本:

class Shop extends Bone {
  @Column()
  name: STRING;

  @Column()
  createdAt: DATE;

  @Column()
  updatedAt: DATE;

  @Column({ type: STRING })
  set name(value) {
    if ([ 'FamilyMart', '7-Eleven' ].includes(value)) {
      this.attribute('name', value);
    }
    throw new Error(`unknown shop name: ${value}`);
  }
}

此时仍然可以直接读取 name

const shop = new Shop({ name: 'FamilyMart' });
assert.equal(shop.name, 'FamilyMart');

脏检查

changed()

对于已经存到数据库的记录来说,只有重新设置过属性,才会认为属性有改动:

const user = await User.first;  // => { name: 'James', login: 'james' }
user.name = 'Jimmy';
user.changed();  // => [ 'name' ]
user.login = 'jimmy';
user.changed();  // => [ 'name', 'login' ]

对于新初始化的模型实例,会认为所有的属性都被改了(从 null 设置成当前值)

const user = new User({ name: 'Jimmy', login: 'Jimmy' });
user.changed();  // => [ 'name', 'login' ]

实例保存之后,会重置属性改动判断

const user = new User({ name: 'Jimmy', login: 'Jimmy' });
user.changed();  // => [ 'name', 'login' ]
await user.save();
user.changed();  // => false

这里需要注意的是,如果没有属性改动,将返回 false 而不是空数组 [] 。这是有意为之,目的是和现有其他库的 API 保持一致。如果需要返回值类型固定,可以考虑使用 changes() ,后者的返回类型始终为对象。

changes()

changes() 是 changed() 的孪生版本,两者判断属性是否有改动的逻辑是一致的。最主要的区别是, changes() 返回的是对象而不是数组,对象中包含有改动的属性在改动之前的值和当前的值。

const user = new User({ name: 'Jimmy', login: 'Jimmy' });
user.changes();  // => { name: [ null, 'Jimmy' ], login: [ null, 'login' ] }

此外,即便没有属性改动, changes() 也会返回 {} ,它的返回类型始终为对象。

previousChanged()

我们可以使用 previousChanged() 来检查模型是否之前有过改动,即使刚刚保存过。

const user = new User({ name: 'Jimmy' });
user.changed();          // => [ 'name' ]
user.previousChanged();  // => false
await user.save();
user.changed();          // => false
user.previousChanged();  // => [ 'name' ]

一般情况下不太会需要使用 previousChanged() ,但是在一些需要事后判断变更的场景,比如 afterCreate 或者 afterUpdate 回调,会特别方便:

User.init(attributes, {
  hooks: {
    afterUpdate(obj) {
      this.previousChanged();  // => check if changed previously or not
    },
  },
});

changes() 类似,可以用 previousChanges() 读取前一个变更版本的具体值。

previousChanges()

const user = new User({ name: 'Jimmy' });
user.changes();          // => { name: [ null, 'Jimmy' ] }
user.previousChanges();  // => {}
await user.save();
user.changes();          // => {}
user.previousChanged();  // => { name: [ null, 'Jimmy' ] }

可以使用 preivousChanges(name) 读取单个属性的变更记录:

const user = new User({ name: 'Jimmy', login: 'jimmy' });
user.changes('login');          // => { name: [ null, 'jimmy' ] }
user.previousChanges('login');  // => {}
await user.save();
user.changes('login');          // => {}
user.previousChanged('login');  // => { name: [ null, 'jimmy' ] }

previousChanges() 和 changes() 的逻辑基本一样,只是对比是前一个版本而非属性当前值。

数据校验

可以通过 allowNull 选项开启数据库自带的非空校验:

class Shop extends Bone {
  static attributes = {
    name: { allowNull: false },
  }
}

也可以使用 Leoric 集成的 validator.js 提供的校验规则:

class Shop extends Bone {
  static attributes = {
    name: {
      type: STRING,
      validate: {
        notIn: [['FamilyMart', '7-Eleven']], // 不是其中任何一个
      },
    },
  }
}

还可以在模型属性定义中自定义验证器:

class User extends Bone {
  static attributes = {
    desc: {
      type: DataTypes.STRING,
      validate: {
        isValid() {
          if (this.desc && this.desc.length < 2) { // 可通过 this 访问属性值
            throw new Error('Invalid desc');
          }
        },
      }
    }
  }
}

详细使用参考《数据校验》 帮助文档。

钩子

可以通过声明对应的静态方法来配置钩子,具体作用顾名思义:

class Shop extends Bone {
  static beforeCreate() {}
  static afterUpdate() {}
});

可配置的钩子列表和详细使用参考《钩子》 帮助文档。

迁移任务

使用 realm.createMigrationFile(name) 来创建迁移任务:

const Realm = require('leoric');
const realm = new Realm({
  client: 'mysql',
  migrations: 'database/migrations',
});

await realm.createMigrationFile('create-products');
// 将会在 database/migrations 目录下创建文件名类似 20210621170235-create-products.js 的文件

使用 realm.migrate() 执行迁移任务,也可以指定步数来控制执行的任务数量,比如 realm.migrate(1) 单步执行;还可以使用 realm.rollback() 回滚迁移任务,同样支持指定步数来控制回滚的任务数量。

迁移任务需要实现对应的 async up()async down(),例如下面这个用来创建产品表的迁移任务:

module.exports = {
  async up(driver, DataTypes) {
    const { BIGINT, STRING, TEXT } = DataTypes;
    await driver.createTable('products', {
      id: { type: BIGINT, primaryKey: true },
      name: STRING,
      description: TEXT,
    });
  },

  async down(driver, DataTypes) {
    await driver.dropTable('products');
  },
}

详细使用参考《迁移任务》 帮助文档。