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

38 KiB
Raw Blame History

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通过类属性映射表字段装饰器风格来配置字段属性

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

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

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 风格
sequelize.define('User', {
  // Model attributes are defined here
  firstName: {
    type: DataTypes.STRING,
    allowNull: false
  },
  lastName: {
    type: DataTypes.STRING
    // allowNull defaults to true
  }
}, {
  // model options
});
  1. 改进之后的 Model 风格
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
  1. 装饰器风格
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 如下:

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 进一步抽象,代码就精炼多了,也可以支持装饰器风格,比如这样:

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 就用的它。

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 提供了丰富的参数,支持删掉重建、只新增不删除、只同步某些表等等处理。

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 工具,具体写法如:

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 方式。

createConnection({
    type: "mysql",
    host: "localhost",
    port: 3306,
    username: "root",
    password: "admin",
    database: "test",
    entities: [
        Photo
    ],
    synchronize: true,
    logging: false
});

Migration 如下,具体的 QueryRunner 细节大家可以看官网,提供了一套标准的 Migration API

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。

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 章节

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 模式
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");
  1. Data Mapper 模式
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

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 实现更复杂查询的支持就弱爆了,比如:

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 章节。

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可造性非常强。

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
  • 我非常喜欢 Objection.js 在 QueryBuilder 上的表现力,可造性非常强,但是要完整的无代码支持,工作量也非常多

在这样的大环境下,无代码的 Database 要如何选择 ORM 和改造呢? 综合考虑Sequelize 最适合作为蓝本,基于 Sequelize 优先实现第一版,即使 Sequelize 存在问题,后续也可以替换为 Typeorm 或 Objection.js 等。

改造开始

Collection

NocoBase 首先基于 Sequelize 的 ModelOptions、 ModelAttributes、ModelAssociations 提炼了一套更适合无代码配置的 JSON 风格的配置协议,称之为 Collection Schema Language简称 CSL。示例如下

// 用户
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

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 都可以自定义,如:

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

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

repository.findOne({
  // 过滤
  filter: {},
  // 为更快速的 pk 过滤提供
  filterByPk: 1, // 通常情况等同于 filter: {id: 1}
  // 字段白名单
  fields: [],
  // 附加字段,主要用于附加关系字段
  appends: [],
  // 字段黑名单
  expect: [],
});

create

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

repository.update({
  // 待更新数据
  values: {},
  // 过滤,哪些数据要更新
  filter: {},
  // 为更快速的 pk 过滤提供
  filterByPk: 1, // 通常情况等同于 filter: {id: 1}
  // 字段白名单
  whitelist: [],
  // 字段黑名单
  blacklist: [],
  // 指定需要更新数据的关联字段
  updateAssociations: [],
});

destroy

// 特定 primary key 值
repository.destroy(1);

// 批量 primary key 值
repository.destroy([1, 2, 3]);

// 复杂的 filter
repository.destroy({
  filter: {},
});

relation

关系数据的 CRUD用法与常规 Repository 的一致

// user_id = 1 的 post 的 repository
const userPostsRepository = repository.relation('posts').of(1);

userPostsRepository.findMany({
  
});

userPostsRepository.findOne({
  
});

userPostsRepository.create({
  values: {},
});

userPostsRepository.update({
  values: {},
});

userPostsRepository.destroy({
  
});

关联操作,只处理关系约束的建立与解除

// 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但因为关系表不存在并不会创建

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
  • 不需要考虑关系外键要怎么建立,在哪里建立,也不需要考虑顺序问题
  • 自动创建多对多中间表以及相关外键及约束
// 文章
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 两张表。

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() 来动态创建表了,比如:

await Collection.repository.create({
  values: {
    name: 'test',
    fields: [
      { name: 'name', type: 'string' },
    ],
  },
});

以上就是 plugin-collections 的核心实现逻辑了。