tachybase_todo/docs/guide/kernel-principle/choose-an-orm.zh-CN.md
chenos ca7af9c8cc
feat: new version of the documentation (#95)
* feat: new version of the documentation

* feat: add English catalog translation

* Update quickstart.md

* Update quickstart.zh-CN.md

* Update quickstart.zh-CN.md

* Update quickstart.zh-CN.md

* Update quickstart.zh-CN.md

* feat: update quickstart

* update doc

* update pepository api doc

Co-authored-by: ChengLei Shao <chareice@live.com>
2021-10-28 22:55:51 +08:00

1269 lines
38 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
order: 4
---
# 如何选择 ORM
本篇文章,需要读者至少熟悉一种较流行的 ORM对 Model、Migration、QueryBuilder、Repository 也有所了解。在正式介绍 NocoBase 的 Database 设计之前,先来看看大部分 ORM 都有的三个概念:
- ModelModelAttributes、EntityEntitySchema将数据表与模型类或实体类对应起来
- Migration、Sync API用于创建、修改、删除数据库表、字段、索引等
- QueryBuilder、EntityManager、Repository、CRUD API提供增删改查
## Model/Entity
无代码的第一个改造,**Model 动态化**。
简单来说 Model/Entity 的作用就是将数据表、字段、索引、关系映射到类、属性或方法上。我们先来看看 Node.js 里各个 ORM 都是怎么做的。
### Typeorm
在 Typeorm 里叫 Entity通过类属性映射表字段装饰器风格来配置字段属性
```typescript
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Photo {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 100
})
name: string;
@Column("text")
description: string;
@Column()
filename: string;
@Column("double")
views: number;
@Column()
isPublished: boolean;
}
```
这种写法,如果 Entity 有修改,需要修改代码,不过 Typeorm 提供了 JSON 风格的 EntitySchema
```typescript
import {EntitySchema} from "typeorm";
export const CategoryEntity = new EntitySchema({
name: "category",
columns: {
id: {
type: Number,
primary: true,
generated: true
},
name: {
type: String
}
}
});
export const PostEntity = new EntitySchema({
name: "post",
columns: {
id: {
type: Number,
primary: true,
generated: true
},
title: {
type: String
},
text: {
type: String
}
},
relations: {
categories: {
type: "many-to-many",
target: "category" // CategoryEntity
}
}
});
```
修改 JSON 就容易多了,这种写法非常适用于无代码平台。平台配置 JSON 动态生成对应的 Entity。
### Prisma
与 Typeorm 装饰器的风格非常接近,但不同的是 Prisma 另辟蹊径,提供了自成一套的 PSLPrisma Schema Language
```ts
datasource db {
url = env("DATABASE_URL")
provider = "postgresql"
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
email String @unique
name String?
role Role @default(USER)
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
published Boolean @default(false)
title String @db.VarChar(255)
author User? @relation(fields: [authorId], references: [id])
authorId Int?
}
enum Role {
USER
ADMIN
}
```
这种写法,需要修改 PSL 文件,除非自己实现一套 PSL 解析与生成器,不然没办法直接无代码改造。
### Sequelize
作为老牌 ORM下载量和使用量也是惊人提供了多种配置 Model 的风格:
1. 传统的 JSON 风格
```ts
sequelize.define('User', {
// Model attributes are defined here
firstName: {
type: DataTypes.STRING,
allowNull: false
},
lastName: {
type: DataTypes.STRING
// allowNull defaults to true
}
}, {
// model options
});
```
2. 改进之后的 Model 风格
```ts
const { Sequelize, DataTypes, Model } = require('sequelize');
const sequelize = new Sequelize('sqlite::memory');
class User extends Model {}
User.init({
// Model attributes are defined here
firstName: {
type: DataTypes.STRING,
allowNull: false
},
lastName: {
type: DataTypes.STRING
// allowNull defaults to true
}
}, {
// Other model options go here
sequelize, // We need to pass the connection instance
modelName: 'User' // We need to choose the model name
});
// the defined model is the class itself
console.log(User === sequelize.models.User); // true
```
3. 装饰器风格
```ts
import { Table, Column, Model, HasMany } from 'sequelize-typescript'
@Table
class Person extends Model {
@Column
name: string
@Column
birthday: Date
@HasMany(() => Hobby)
hobbies: Hobby[]
}
```
Sequelize 可以使用 JSON 风格配置 Model非常适用于无代码平台。平台配置 JSON 动态生成对应的 Model。
### Objection.js
基于 KnexModel 如下:
```typescript
const { Model } = require('objection');
class Person extends Model {
// Table name is the only required property.
static get tableName() {
return 'persons';
}
// Each model must have a column (or a set of columns) that uniquely
// identifies the rows. The column(s) can be specified using the `idColumn`
// property. `idColumn` returns `id` by default and doesn't need to be
// specified unless the model's primary key is something else.
static get idColumn() {
return 'id';
}
// Methods can be defined for model classes just as you would for
// any JavaScript class. If you want to include the result of these
// methods in the output json, see `virtualAttributes`.
fullName() {
return this.firstName + ' ' + this.lastName;
}
// Optional JSON schema. This is not the database schema!
// No tables or columns are generated based on this. This is only
// used for input validation. Whenever a model instance is created
// either explicitly or implicitly it is checked against this schema.
// See http://json-schema.org/ for more info.
static get jsonSchema() {
return {
type: 'object',
required: ['firstName', 'lastName'],
properties: {
id: { type: 'integer' },
parentId: { type: ['integer', 'null'] },
firstName: { type: 'string', minLength: 1, maxLength: 255 },
lastName: { type: 'string', minLength: 1, maxLength: 255 },
age: { type: 'number' },
// Properties defined as objects or arrays are
// automatically converted to JSON strings when
// writing to database and back to objects and arrays
// when reading from database. To override this
// behaviour, you can override the
// Model.jsonAttributes property.
address: {
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' },
zipCode: { type: 'string' }
}
}
}
};
}
// This object defines the relations to other models.
static get relationMappings() {
// Importing models here is a one way to avoid require loops.
const Animal = require('./Animal');
const Movie = require('./Movie');
return {
pets: {
relation: Model.HasManyRelation,
// The related model. This can be either a Model
// subclass constructor or an absolute file path
// to a module that exports one. We use a model
// subclass constructor `Animal` here.
modelClass: Animal,
join: {
from: 'persons.id',
to: 'animals.ownerId'
}
},
movies: {
relation: Model.ManyToManyRelation,
modelClass: Movie,
join: {
from: 'persons.id',
// ManyToMany relation needs the `through` object
// to describe the join table.
through: {
// If you have a model class for the join table
// you need to specify it like this:
// modelClass: PersonMovie,
from: 'persons_movies.personId',
to: 'persons_movies.movieId'
},
to: 'movies.id'
}
},
children: {
relation: Model.HasManyRelation,
modelClass: Person,
join: {
from: 'persons.id',
to: 'persons.parentId'
}
},
parent: {
relation: Model.BelongsToOneRelation,
modelClass: Person,
join: {
from: 'persons.parentId',
to: 'persons.id'
}
}
};
}
}
```
直观感受,并没有 Typeorm、Prisma、Sequelize 精炼。不过 Objection.js 提供了 mixin也可以将 Model 进一步抽象,代码就精炼多了,也可以支持装饰器风格,比如这样:
```ts
const { compose, Model } = require('objection');
const mixins = compose(
SomeMixin,
SomeOtherMixin,
EvenMoreMixins,
LolSoManyMixins,
ImAMixinWithOptions({ foo: 'bar' })
);
class Person extends mixins(Model) {}
@SomeMixin
@MixinWithOptions({ foo: 'bar' })
class Person extends Model {}
```
从构思上来说,可改造空间大,也可以巧妙的将各种配置 JSON 化,从而达到动态生成的 Model 目的。
### Bookshelf.js
基于 Knex也是类 JSON 的配置风格,一些非常流行的开源项目 Ghost、Strapi 就用的它。
```typescript
const knex = require('knex')({
client: 'mysql',
connection: process.env.MYSQL_DATABASE_CONNECTION
})
const bookshelf = require('bookshelf')(knex)
const User = bookshelf.model('User', {
tableName: 'users',
posts() {
return this.hasMany(Posts)
}
})
const Post = bookshelf.model('Post', {
tableName: 'posts',
tags() {
return this.belongsToMany(Tag)
}
})
const Tag = bookshelf.model('Tag', {
tableName: 'tags'
})
```
同样支持动态化改造。Bookshelf 的可改造空间巨大,不过看似作者已经不再维护了。
### 总结
到底哪个好呢?**仅从动态化 Model/Entity 角度来说:**
- Sequelize 细节做的最好
- Typeorm 非常活跃,才迭代了 v0.2.38,但 stars 就已经超 25k+ 以上所有 ORM 关注度最多,装饰器的风格也被大家所喜爱。但是 EntitySchema 的细节做的还不够,需要进一步优化和改造,存在许多未知
- Objection.js 的构思非常不错,尤其是 mixin灵活性和可改造性非常强
- Bookshelf.js 也不错如果是早几年Bookshelf 可能会是我的第一选择
- 至于 Prisma特立独行的 PSL深受大家喜爱但是 PSL 并不支持动态化 Model
以上 ORM 都是以关系型数据库为主,不过 Typeorm 和 Prisma 也支持 MongoDB细节有差异只支持 MongoDB 的 ORM 不在讨论范围内。
## Migration
有了 Model/Entity 之后,需要创建对应的数据库表、字段和索引。上文提及的大多数 Model/Entity 都可以详细的描述字段的属性和关系Model 的 DSL理论上就可以直接生成表和关系约束了而并不需要单独再配置 migration 文件了。比如:
### Sequelize
提供了 `sequelize.sync()``Model.sync()` 方法,可以快速的根据 Model Attributes 生成数据表、字段和索引。sync 提供了丰富的参数,支持删掉重建、只新增不删除、只同步某些表等等处理。
```typescript
const User = sequelize.define('User', {
// Model attributes are defined here
firstName: {
type: DataTypes.STRING,
allowNull: false
},
lastName: {
type: DataTypes.STRING
// allowNull defaults to true
}
}, {
// Other model options go here
});
await sequelize.sync();
// Or
await User.sync();
```
除了 syncSequelize 也提供了 migration 工具,具体写法如:
```typescript
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Person', {
name: Sequelize.DataTypes.STRING,
isBetaMember: {
type: Sequelize.DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('Person');
}
};
```
在生产环境或者希望精确控制时,都可以通过 Sequelize 提供的 queryInterface 来处理。
### Typeorm
没有给力的 sequelize.sync() 方法,但是提供了 synchronize: true 配置参数,效果类似,会自动创建表,因为会重新建表,并不适用于生产环境。生产环境建议使用更为安全的 migration 方式。
```typescript
createConnection({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "admin",
database: "test",
entities: [
Photo
],
synchronize: true,
logging: false
});
```
Migration 如下,具体的 QueryRunner 细节大家可以看官网,提供了一套标准的 Migration API
```typescript
import {MigrationInterface, QueryRunner, Table, TableIndex, TableColumn, TableForeignKey } from "typeorm";
export class QuestionRefactoringTIMESTAMP implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(new Table({
name: "question",
columns: [
{
name: "id",
type: "int",
isPrimary: true
},
{
name: "name",
type: "varchar",
}
]
}), true)
await queryRunner.createIndex("question", new TableIndex({
name: "IDX_QUESTION_NAME",
columnNames: ["name"]
}));
await queryRunner.createTable(new Table({
name: "answer",
columns: [
{
name: "id",
type: "int",
isPrimary: true
},
{
name: "name",
type: "varchar",
},
{
name: 'created_at',
type: 'timestamp',
default: 'now()'
}
]
}), true);
await queryRunner.addColumn("answer", new TableColumn({
name: "questionId",
type: "int"
}));
await queryRunner.createForeignKey("answer", new TableForeignKey({
columnNames: ["questionId"],
referencedColumnNames: ["id"],
referencedTableName: "question",
onDelete: "CASCADE"
}));
}
async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable("answer");
const foreignKey = table.foreignKeys.find(fk => fk.columnNames.indexOf("questionId") !== -1);
await queryRunner.dropForeignKey("answer", foreignKey);
await queryRunner.dropColumn("answer", "questionId");
await queryRunner.dropTable("answer");
await queryRunner.dropIndex("question", "IDX_QUESTION_NAME");
await queryRunner.dropTable("question");
}
}
```
### Knex
Objection.js 和 Bookshelf 都基于 Knex所以 Migration 都使用的 Knex。
```typescript
knex.schema.createTable('users', function (table) {
table.increments();
table.string('name');
table.timestamps();
})
// Outputs:
// create table `users` (`id` int unsigned not null auto_increment primary key, `name` varchar(255), `created_at` datetime, `updated_at` datetime)
```
Knex 和 Laravel 的 QueryBuilder 非常接近Schema Builder 也如此。单纯从 Migration API 设计来说,个人更喜欢这种语法风格,干净、简洁。
### Prisma
Migration 的思路与上述的做法不同Prisma 提供了完整的配置 Model 的 PSL 语法,为开发环境提供了 migrate dev 支持,每次修改 PSL 文件之后,可以执行 migrate dev 生成对应变更的 sql 文件,生产环境再执行 migrate 命令来同步修改,详情查看官方文档 [Prisma Migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate) 章节
```bash
migrations/
└─ 20210305110829_first_migration/
└─ migration.sql
└─ 20210305120829_add_fields/
└─ migration.sql
└─ 20210308102042_type-change/
└─ migration.sql
```
### 总结
- 流程上Prisma 的做法最省事儿,只配置一份 PSL每次修改之后通过 migrate dev 命令生成 sql无需修改。
- 其次是 Sequelize提供了 sequelize.sync 方法,配置的 Model 都可以通过 sync 方法同步给数据库。但是这种方式有些暴力和冗余,如果能稍加改进就更好了。
- 至于传统的 Migration 做法,配置 Model 已经写了一份 DSL 了,配置 Migration 再写另外一份 DSL非常不友好无代码改造也非常困难。
**怎么无代码改造来解决 Migration 问题呢?**
Sequelize 的方案非常不错,虽然有些暴力和冗余,但是可以再稍加改进,尤其是需要达到生产环境的精准控制。
**sequelize.sync 是否会有安全问题呢?**
因为 sync 支持 force: true 参数,会强制删除重建,在生产环境要关掉。
修改了 Model 的 DSL在 sync 里怎么判断是创建、修改或删除呢?篇幅有限这里就不细说了。
## QueryBuilder、EntityManager、Repository、CRUD API
有了 Model/Entity也创建数据表和字段了那接下来就能操控数据库了。Model/Entity 常见有两种模式 Active Record 和 Data MapperTypeorm 支持的最完整,所以我们先来看看 Typeorm 吧。
### Typeorm
1. Active Record 模式
```typescript
import {BaseEntity, Entity, PrimaryGeneratedColumn, Column} from "typeorm";
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isActive: boolean;
static findByName(firstName: string, lastName: string) {
return this.createQueryBuilder("user")
.where("user.firstName = :firstName", { firstName })
.andWhere("user.lastName = :lastName", { lastName })
.getMany();
}
}
// example how to save AR entity
const user = new User();
user.firstName = "Timber";
user.lastName = "Saw";
user.isActive = true;
await user.save();
// example how to remove AR entity
await user.remove();
// example how to load AR entities
const users = await User.find({ skip: 2, take: 5 });
const newUsers = await User.find({ isActive: true });
const timber = await User.findOne({ firstName: "Timber", lastName: "Saw" });
const timber = await User.findByName("Timber", "Saw");
```
2. Data Mapper 模式
```typescript
import {Entity, EntityRepository, Repository, PrimaryGeneratedColumn, Column} from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isActive: boolean;
}
// custom repository
@EntityRepository()
export class UserRepository extends Repository<User> {
findByName(firstName: string, lastName: string) {
return this.createQueryBuilder("user")
.where("user.firstName = :firstName", { firstName })
.andWhere("user.lastName = :lastName", { lastName })
.getMany();
}
}
const userRepository = connection.getRepository(User);
// example how to save DM entity
const user = new User();
user.firstName = "Timber";
user.lastName = "Saw";
user.isActive = true;
await userRepository.save(user);
// example how to remove DM entity
await userRepository.remove(user);
// example how to load DM entities
const users = await userRepository.find({ skip: 2, take: 5 });
const newUsers = await userRepository.find({ isActive: true });
const timber = await userRepository.findOne({ firstName: "Timber", lastName: "Saw" });
// custom repository
const userRepository = connection.getCustomRepository(UserRepository);
const timber = await userRepository.findByName("Timber", "Saw");
```
从无代码、低代码改造角度来说Data Mapper 模式更适合,通过 EntitySchema 生成 Entity再交给 EntityManager 或 Repository 来处理数据的增删改查,也可以根据需要自定义 Repository。这就是低代码的扩展能力通用的 Repository 处理常规需求,特殊需求自定义扩展。除了常用的 CRUD APITypeorm 也提供了强大的 QueryBuilder 来自定义其他 API如上文例子的 findByName。
### Sequelize
Sequelize 的 Model 是 Active Record 模式,提供了常用的 CRUD API
```ts
const jane = await User.create({ name: "Jane" });
console.log(jane.name); // "Jane"
jane.name = "Ada";
// the name is still "Jane" in the database
await jane.save();
// Now the name was updated to "Ada" in the database!
Model.findAll({
attributes: ['foo', 'bar']
});
// SELECT foo, bar FROM ...
Post.findAll({
where: {
authorId: 2
}
});
// SELECT * FROM post WHERE authorId = 2
const { Op } = require("sequelize");
Post.findAll({
where: {
[Op.and]: [{ a: 5 }, { b: 6 }], // (a = 5) AND (b = 6)
[Op.or]: [{ a: 5 }, { b: 6 }], // (a = 5) OR (b = 6)
someAttribute: {
// Basics
[Op.eq]: 3, // = 3
[Op.ne]: 20, // != 20
[Op.is]: null, // IS NULL
[Op.not]: true, // IS NOT TRUE
[Op.or]: [5, 6], // (someAttribute = 5) OR (someAttribute = 6)
// Using dialect specific column identifiers (PG in the following example):
[Op.col]: 'user.organization_id', // = "user"."organization_id"
// Number comparisons
[Op.gt]: 6, // > 6
[Op.gte]: 6, // >= 6
[Op.lt]: 10, // < 10
[Op.lte]: 10, // <= 10
[Op.between]: [6, 10], // BETWEEN 6 AND 10
[Op.notBetween]: [11, 15], // NOT BETWEEN 11 AND 15
// Other operators
[Op.all]: sequelize.literal('SELECT 1'), // > ALL (SELECT 1)
[Op.in]: [1, 2], // IN [1, 2]
[Op.notIn]: [1, 2], // NOT IN [1, 2]
[Op.like]: '%hat', // LIKE '%hat'
[Op.notLike]: '%hat', // NOT LIKE '%hat'
[Op.startsWith]: 'hat', // LIKE 'hat%'
[Op.endsWith]: 'hat', // LIKE '%hat'
[Op.substring]: 'hat', // LIKE '%hat%'
[Op.iLike]: '%hat', // ILIKE '%hat' (case insensitive) (PG only)
[Op.notILike]: '%hat', // NOT ILIKE '%hat' (PG only)
[Op.regexp]: '^[h|a|t]', // REGEXP/~ '^[h|a|t]' (MySQL/PG only)
[Op.notRegexp]: '^[h|a|t]', // NOT REGEXP/!~ '^[h|a|t]' (MySQL/PG only)
[Op.iRegexp]: '^[h|a|t]', // ~* '^[h|a|t]' (PG only)
[Op.notIRegexp]: '^[h|a|t]', // !~* '^[h|a|t]' (PG only)
[Op.any]: [2, 3], // ANY ARRAY[2, 3]::INTEGER (PG only)
// In Postgres, Op.like/Op.iLike/Op.notLike can be combined to Op.any:
[Op.like]: { [Op.any]: ['cat', 'hat'] } // LIKE ANY ARRAY['cat', 'hat']
// There are more postgres-only range operators, see below
}
}
});
```
常规的 CRUD API 支持的还不错,但是自定义查询或者基于 QueryBuilder 实现更复杂查询的支持就弱爆了,比如:
```ts
Post.findAll({
where: {
[Op.or]: [
sequelize.where(sequelize.fn('char_length', sequelize.col('content')), 7),
{
content: {
[Op.like]: 'Hello%'
}
},
{
[Op.and]: [
{ status: 'draft' },
sequelize.where(sequelize.fn('char_length', sequelize.col('content')), {
[Op.gt]: 10
})
]
}
]
}
});
Model.findAll({
attributes: [
'id', 'foo', 'bar', 'baz', 'qux', 'hats', // We had to list all attributes...
[sequelize.fn('COUNT', sequelize.col('hats')), 'n_hats'] // To add the aggregation...
]
});
// This is shorter, and less error prone because it still works if you add / remove attributes from your model later
Model.findAll({
attributes: {
include: [
[sequelize.fn('COUNT', sequelize.col('hats')), 'n_hats']
]
}
});
```
Sequelize 在配置 Model 和 Migration 的表现上都比较不错,但是在 QueryBuilder 的支持上弱爆了,几乎不解决各数据库的兼容性问题,提供的 queryInterface 也非常难用,一点也不 SQL-Friendly。
### Prisma
提供了 Prisma Client 用于支持数据的 CRUD用法与 Sequelize 相似。没有提供 QueryBuilder 如果常规 CRUD API 支持的不好,可能很难改造,这部分了解的不多,细节存在非常多未知。感兴趣的看官网 [Prisma Client](https://www.prisma.io/docs/concepts/components/prisma-client) 章节。
```ts
const user = await prisma.user.create({
data: {
email: 'elsa@prisma.io',
name: 'Elsa Prisma',
},
})
const getPosts = await prisma.post.findMany({
where: {
title: {
contains: 'cookies',
},
},
include: {
author: true, // Return all fields
},
})
```
这种风格的 CRUD API 非常不利于调试,一旦出现问题将很难改造。
### Objection.js
得益于 KnexObjection 在 QueryBuilder 的表现上非常不错 SQL-Friendly可造性非常强。
```ts
const jennifer = await Person.query().insert({
firstName: 'Jennifer',
lastName: 'Lawrence'
});
// insert into "persons" ("firstName", "lastName") values ('Jennifer', 'Lawrence')
console.log(jennifer instanceof Person); // --> true
console.log(jennifer.firstName); // --> 'Jennifer'
console.log(jennifer.fullName()); // --> 'Jennifer Lawrence'
const numUpdated = await Person.query()
.patch({ lastName: 'Dinosaur' })
.where('age', '>', 60);
// update "persons" set "lastName" = 'Dinosaur' where "age" > 60
console.log(numUpdated, 'people were updated');
const middleAgedJennifers = await Person.query()
.select('age', 'firstName', 'lastName')
.where('age', '>', 40)
.where('age', '<', 60)
.where('firstName', 'Jennifer')
.orderBy('lastName');
console.log('The last name of the first middle aged Jennifer is');
console.log(middleAgedJennifers[0].lastName);
// select "age", "firstName", "lastName"
// from "persons"
// where "age" > 40
// and "age" < 60
// and "firstName" = 'Jennifer'
// order by "lastName" asc
```
### Bookshelf
一样基于 Knex细节就不一一罗列了
### 总结
除了常规的增删改查,还有一个非常重要的能力就是关系数据的处理能力,关系数据的 eager loading 也是各 ORM 永恒的话题,这里就不细说了
- 从完整性来说Typeorm 的最完整
- 如果说有什么理由让我选择 Objection.js我会说它的 QueryBuilder 非常给力,写起来非常舒服,可造性也非常强
- Sequelize 的常规 CRUD API 并没有太大问题,但是在关系数据的处理上问题太多了。而且黑箱设计的 CRUD API非常难以调试和改造
- Prisma 的 CRUD API 与 Sequelize 相似,这让我非常担心它是否也会有 Sequelize 的各种糟心问题
- 至于 Bookshelf已经不维护了就不深入讨论了Objection.js 是类似最好的替代品
## 如何选择?
- 综合实力 Typeorm 最强,各方面表现的都不差,但细节还差那么点,需要更深入使用才能知道细节表现力,存在非常多未知,但因为社区活跃,成长空间巨大
- 从无代码改造角度来说Sequelize 工作量是最少的,尤其是给力的 sync 方法。但我非常不喜欢它的 QueryBuilder 设计。Sequelize 的社区活跃度也很高,但是核心团队看起来出现了些问题 [issue #12956](https://github.com/sequelize/sequelize/issues/12956)
- 我非常喜欢 Objection.js 在 QueryBuilder 上的表现力,可造性非常强,但是要完整的无代码支持,工作量也非常多
**在这样的大环境下,无代码的 Database 要如何选择 ORM 和改造呢?**
综合考虑Sequelize 最适合作为蓝本,基于 Sequelize 优先实现第一版,即使 Sequelize 存在问题,后续也可以替换为 Typeorm 或 Objection.js 等。
## 改造开始
### Collection
NocoBase 首先基于 Sequelize 的 ModelOptions、 ModelAttributes、ModelAssociations 提炼了一套更适合无代码配置的 JSON 风格的配置协议,称之为 Collection Schema Language简称 CSL。示例如下
```typescript
// 用户
db.collection({
name: 'users',
fields: {
username: { type: 'string', unique: true },
password: { type: 'password', unique: true },
posts: { type: 'hasMany' },
},
});
// 文章
db.collection({
name: 'posts',
fields: {
title: 'string',
content: 'text',
tags: 'belongsToMany',
comments: 'hasMany',
author: { type: 'belongsTo', target: 'users' },
},
});
// 标签
db.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts' },
],
});
// 评论
db.collection({
name: 'comments',
fields: [
{ type: 'text', name: 'content' },
{ type: 'belongsTo', name: 'user' },
],
});
```
与现在流行的装饰器配置的结构很像,只不过它是用 JSON 编写的,便于动态生成 Model。与 Typeorm 的 EntitySchema 做法也非常接近。
**那为什么不直接使用 EntitySchema 呢?**
一方面对 EntitySchema 细节表现力如何未知,另一方面需要考虑后续的自定义字段需求,尤其是 UISchema 的扩展能力,自成一体的 CSL 更适合。而且结构上与 Typeorm 和 Prisma 的装饰器风格非常接近,学习成本并不高。
### Model & Repository
配置好了 CSL 之后,会根据它自动初始化 ORM 的 ModelActive Record和 RepositoryData Mapper
```typescript
const User = app.collection({
name: 'users',
fields: {
username: { type: 'string', unique: true },
password: { type: 'password', unique: true },
posts: { type: 'hasMany' },
},
});
User.model;
User.repository;
```
Model 的具体形态取决于适配的 ORM保留原滋原味细节就不多说了。Repository 自成一体,提供更适合 NocoBase 的 CRUD API。Model 和 Repository 都可以自定义,如:
```ts
class UserModel extends Model {
}
class UserRepository extends Repository {
}
const User = app.collection({
name: 'users',
model: UserModel,
repository: UserRepository,
fields: {
username: { type: 'string', unique: true },
password: { type: 'password', unique: true },
posts: { type: 'hasMany' },
},
});
```
支持 Model 也支持 Repository与 Typeorm 的做法非常接近,但是 Repository 并不是 Typeorm 的 Repository我们来看看 Repository API 吧。
### Repository API
NocoBase 的 Repository API 有:
- repository.findMany()
- repository.findOne()
- repository.create()
- repository.update()
- repository.destroy()
- repository.relation().of()
- findMany()
- findOne()
- create()
- update()
- destroy()
- set()
- add()
- remove()
- toggle()
#### findMany
```ts
repository.findMany({
// 过滤
filter: {
$and: [{ a: 5 }, { b: 6 }], // (a = 5) AND (b = 6)
$or: [{ a: 5 }, { b: 6 }], // (a = 5) OR (b = 6)
someAttribute: {
// Basics
$eq: 3, // = 3
$ne: 20, // != 20
$is: null, // IS NULL
$not: true, // IS NOT TRUE
$gt: 6, // > 6
$gte: 6,
},
'someAttribute.$eq': 3,
'nested.someAttribute': {
//
},
nested: {
someAttribute: {},
},
},
// 字段白名单
fields: [],
// 附加字段,主要用于附加关系字段
appends: [],
// 字段黑名单
expect: [],
sort: [],
page: 1,
pageSize: 2,
});
```
#### findOne
```typescript
repository.findOne({
// 过滤
filter: {},
// 为更快速的 pk 过滤提供
filterByPk: 1, // 通常情况等同于 filter: {id: 1}
// 字段白名单
fields: [],
// 附加字段,主要用于附加关系字段
appends: [],
// 字段黑名单
expect: [],
});
```
#### create
```ts
repository.create({
// 待存数据
values: {
a: 'a',
// 快速建立关联
o2o: 1, // 建立一对一关联
m2o: 1, // 建立多对一关联
o2m: [1,2] // 建立一对多关联
m2m: [1,2] // 建立多对多关联
// 新建关联数据并建立关联
o2o: {
key1: 'val1',
},
o2m: [{key1: 'val1'}, {key2: 'val2'}],
// 子表格数据
subTable: [
// 如果数据存在,更新处理
{id: 1, key1: 'val1111'},
// 如果数据不存在,直接创建并关联
{key2: 'val2'},
],
},
// 字段白名单
whitelist: [],
// 字段黑名单
blacklist: [],
// 关系数据默认会新建并建立关联处理,如果是已存在的数据只关联,但不更新关系数据
// 如果需要更新关联数据,可以通过 updateAssociations 指定
updateAssociations: ['subTable'],
});
```
#### update
```ts
repository.update({
// 待更新数据
values: {},
// 过滤,哪些数据要更新
filter: {},
// 为更快速的 pk 过滤提供
filterByPk: 1, // 通常情况等同于 filter: {id: 1}
// 字段白名单
whitelist: [],
// 字段黑名单
blacklist: [],
// 指定需要更新数据的关联字段
updateAssociations: [],
});
```
#### destroy
```ts
// 特定 primary key 值
repository.destroy(1);
// 批量 primary key 值
repository.destroy([1, 2, 3]);
// 复杂的 filter
repository.destroy({
filter: {},
});
```
#### relation
关系数据的 CRUD用法与常规 Repository 的一致
```ts
// user_id = 1 的 post 的 repository
const userPostsRepository = repository.relation('posts').of(1);
userPostsRepository.findMany({
});
userPostsRepository.findOne({
});
userPostsRepository.create({
values: {},
});
userPostsRepository.update({
values: {},
});
userPostsRepository.destroy({
});
```
关联操作,只处理关系约束的建立与解除
```ts
// user_id = 1 的 post 的 relatedQuery
const userPostsRepository = repository.relation('posts').of(1);
// 建立关联
userPostsRepository.set(1);
// 批量,仅用于 HasMany 和 BelongsToMany
userPostsRepository.set([1,2,3]);
// BelongsToMany 的中间表
userPostsRepository.set([
[1, {/* 中间表数据 */}],
[2, {/* 中间表数据 */}],
[3, {/* 中间表数据 */}],
]);
// 仅用于 HasMany 和 BelongsToMany
userPostsRepository.add(1);
// BelongsToMany 的中间表
userPostsRepository.add(1, {/* 中间表数据 */});
// 删除关联
userPostsRepository.remove(1);
// 建立或解除
userPostsRepository.toggle(1);
userPostsRepository.toggle([1, 2, 3]);
```
### Collection Sync
在 Migration 章节,介绍了各种 ORM 的 Migration 做法。得出结论 Sequelize.sync() 的方案较优但不够精细而且有些暴力不过也没有关系Collection 也打算这样做,再进一步的改进 sync 的细节,流程上就变得非常友好了。
只执行某个 collection 的 sync。虽然有 hasMany 的 posts但因为关系表不存在并不会创建
```ts
const User = db.collection({
name: 'users',
fields: {
username: { type: 'string', unique: true },
password: { type: 'password', unique: true },
posts: { type: 'hasMany' },
},
});
await User.sync();
```
我们也可以通过 db.sync 批量的将多个 collections 同步给数据库通常不需要关注建表顺序、关系主外键和关系约束的先后顺序等等collection 内部通通自动帮你处理好。比如下面例子:
- 不需要特意声明外键 user_id
- 不需要考虑关系外键要怎么建立,在哪里建立,也不需要考虑顺序问题
- 自动创建多对多中间表以及相关外键及约束
```ts
// 文章
db.collection({
name: 'posts',
fields: {
title: 'string',
content: 'text',
tags: 'belongsToMany',
comments: 'hasMany',
author: { type: 'belongsTo', target: 'users' },
},
});
// 用户
db.collection({
name: 'users',
fields: {
username: { type: 'string', unique: true },
password: { type: 'password', unique: true },
posts: { type: 'hasMany' },
},
});
// 标签
db.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'posts' },
],
});
// 评论
db.collection({
name: 'comments',
fields: [
{ type: 'text', name: 'content' },
{ type: 'belongsTo', name: 'user' },
],
});
await db.sync();
```
### 动态化
为了更好的支持动态配置 collection提供了以下 API
- db.collection 创建
- db.getCollection 获取
- collection.mergeOptions 和 collection 配置,不删除
- collection.hasField
- collection.getField
- collection.addField
- collection.setFields
- collection.mergeField 合并参数,如果 field 不存在则添加
- collection.removeField 移除
- collection.sync
备注name 是非常重要的标识,但如果涉及 name 的修改如何处理比较合适?
有了 Collection API 就可以实现动态 Collection 了。解决动态数据的持久化问题,可以将数据储存在数据表里。为此,我们可以创建 collections 和 fields 两张表。
```ts
const Collection = db.collection({
name: 'collections',
fields: [
{ name: 'name', type: 'string', unique: true },
{ name: 'fields', type: 'hasMany', foreignKey: 'collectionName' },
],
});
const Field = db.collection({
name: 'fields',
fields: [
{ name: 'name', type: 'string' },
],
});
db.on('collections.afterCreate', async (model) => {
const collection = db.collection(model.toJSON()); // 实际可能不是用 model.toJSON() 方法
await collection.sync();
});
db.on('collections.afterUpdate', async (model) => {
const collection = db.getCollection(model.get('name'));
// 更新配置
collection.mergeOptions(model.get());
await collection.sync();
});
db.on('fields.afterCreate', async (model) => {
const collection = db.getCollection(model.get('collectionName'));
// 新增字段
collection.addField(model.toJSON()); // 实际可能不是用 model.toJSON() 方法
await collection.sync();
});
db.on('fields.afterUpdate', async (model) => {
const collection = db.getCollection(model.get('collectionName'));
// 更新字段配置
collection.mergeField(model.toJSON()); // 实际可能不是用 model.toJSON() 方法
await collection.sync();
});
```
备注db.on 之后会另起一篇和 app.on 一起介绍
这样就可以用 Collection.repository.create() 来动态创建表了,比如:
```ts
await Collection.repository.create({
values: {
name: 'test',
fields: [
{ name: 'name', type: 'string' },
],
},
});
```
以上就是 plugin-collections 的核心实现逻辑了。