# 开发指南

# 关于 Aca.ts

Aca.ts 是一个全栈开发工具,它能根据一个 Typescript ORM 文档,自动同步数据库、生成数据库查询的 API;根据后端函数及数据库查询的 API,自动生成前端访问后端的 API,以实现后端代码在前端开发、测试。

# 特点

  • 支持数据库事务
  • 支持数据库表和列的映射(map)
  • 支持多个数据库
  • 支持表和函数命名空间
  • 前后端数据交互接口自动生成(像 RPC,不需定义路由)
  • 和框架无关,适配现有任何前后端 JS 框架
  • 后端代码在前端开发、测试,免去和后端沟通
  • TS 代码提示完善,无需看文档即可开发

# 快速开始

先决条件:请先安装(如果没有)node.js(v18.0.0^) 和 ts-node。
在终端按顺序运行如下的命令(如果提示权限拒绝访问,请加 sudo 前缀):

$ npm install -g aca.ts
$ aca create acaProject && cd acaProject
$ aca server
$ aca client
$ aca up

然后,分别在两个终端启动 server 和 client:
server 目录下运行:

$ npm run dev

client 目录下运行:

$ npm start

示例内置了一个 Better-Sqlite3 数据库,点击打开的页面上的按钮,会创建一条数据库 user 表记录,并返回其 id。
具体代码查看 src/App.tsx 页面,展示了如何在前端使用数据库 API 和 RPC 函数。

# 配置

config.json 位于.aca 目录下,具体字段说明如下:

  • ORM: 你自己编写的 typescript ORM 文件的名字,该 ORM 文件必须存放在.aca 目录下

  • databases: 数据库相关配置(支持多数据库),字段名为 ORM 文件中定义数据库模型时指定的变量名,默认为:default,可以指定的数据库配置有:

    • tableNameWithNamespace:默认为 true,定义 ORM 时,支持使用命名空间分类各个表定义,aca 在创建数据库时,是否也要加上命名空间的前缀,如果为 false,则数据库表的名字就只是你定义的 class 的名字
    • onlyApi: 默认为 false, 当你项目中需要使用如第三方提供的数据库时,你不能去修改这样的数据库架构,只生成 API,不同步数据库架构
    • idDefaultType:默认为"cuid",当 id 类型不指定时,默认使用此类型
    • foreignKeyConstraint: 默认为 true,外键表使用外键约束
    • onUpdate onDelete:默认为"cascade" 当主键修改或删除时,外键的动作
    • connectOption:数据库连接选项,参考 Knex.js 的连接选项
  • serverApps;为一个字典对象,项目下所有的后端应用均登记于此,也可以将自己创建的后端应用相关信息填于此(与通过 aca add 命名添加效果一样);

    • 字段名:为 app 的名字,
    • apiDir:存放生成的 API 的路径名(相对于该 App),默认为:"src/aca.server"
  • clientApps:为一个字典对象,项目下所有的前端应用均登记于此,也可以将自己创建的前端应用相关信息填于此(与通过 aca add 命名添加效果一样)

    • 字段名:为 app 的名字,
    • apiDir:存放生成的 API 的路径名(相对于该 App),默认为:"src/aca.client"
    • allowRPCs:为一个数组,指明该应用只能访问后端的 RPC 服务器列表,如果没有该字段,则默认为该项目下所有的后端应用的 RPC 都可访问

# CLI

Aca.ts 有如下命令:

  1. 创建一个新项目:
   $ aca create [projectName]

projectName: 你自己取的项目名 运行该命令会在当前目录创建一个 projectName 文件夹,该文件夹下含有一个名为.aca 的文件夹,其中配置文件 config.json 就位于.aca 文件夹下
以下命令需在 projectName 目录或创建的 app 目录下运行。

  1. 创建一个后端服务:
$ aca server <appName>

appName 默认为:server
创建一个基于 koa 框架的简单后端服务

  1. 创建一个前端服务:
$ aca client <appName>

appName 默认为:client
调用 create-react-app 创建一个 react app 应用

  1. 将自己创建的应用添加到项目中(需保证该应用在 aca 项目目录下):
$ aca add <appName> --server --client --apiDir <path>

appName:为你自己创建的这个 app 的名字
--server(-s):指明是后端 App
--client(-c):指明是前端 App
--apiDir(-a):生成的 API 存放的路径
path:相对于该 App 的路径,默认为:src/aca.server 或 src/aca.client,如果同时指定-s 和-c,则-a 使用默认路径

  1. 生成 API:
$ aca up

生成前后端的 API, 并自动同步数据库架构(如果有改变的话)。API 存放于项目中各个 App 指定的目录下

  1. 数据库架构回滚:
$ aca rollback

会将数据库架构恢复到上一次改变,注意:被删除了的列的数据不能被恢复

# ORM

ORM 的定义可以参考 example-blog.ts 编写,具体说明如下:
支持的标量数据类型有:id、boolean、string、int、float、object(json 对象)、Date、enum(枚举类型)

  • id:每张表只有一个 id,支持 5 种类型:cuid uuid autoincrement int string, 写法如:id['autoincrement'],如果只写 id,则根据.aca/config.json/idDefaultType 指定,如果也不存在,则默认为:cuid

  • boolean:布尔类型(true false)

  • string:字符串类型

  • int:整型

  • float:浮点型

  • object:json 对象类型

  • Date:日期时间类型

  • enum:自定义枚举类型

  • 标量数组:目前只支持 pg

  • 定义一个数据库:用一个顶级命名空间代表一个数据库,命名空间的名字指向你的一个数据库,包含一个可选的常量定义,和多个类定义,类定义也可以使用命名空间进行组织

  • 可选的常量定义:类型为字符串,其值为你在.aca/config.json/database 中定义的一个字段名,如果没有这个定义,则默认为:default

  • 定义一个表:用类定义语法:

    • 数据库表的名字:类名
    • 数据库表的列名:字段名
    • 列的数据类型:字段类型
    • 表之间的关系:字段名,类型为表的名字
  • 装饰器:

    • 类装饰器:集成在$装饰器中,有 map、id、unique、index、deprecated
      • @$.map(name);数据库表的名字为:name
      • @$.id(fields):指定 id,fields: 列名组成的数组
      • @$.unique(fields):指定为 unique 约束,fields: 列名组成的数组
      • @$.index(fields):指定建表索引,fields: 列名组成的数组
      • @$.deprecated;指明该表已被废弃
    • 类成员装饰器:集成在$_装饰器中,有:unique、index、dbType、createdAt、updatedAt、foreign、deprecated
      • @$_.unique:表明该字段为 unique 字段
      • @$_.index:表明该字段为 index 字段
      • @$_.dbType(name):定义该字段的数据库类型,name 必须为该数据库支持的类型的名字,如:varchar(23)
      • @$_.createdAt、@$_.updatedAt:数据记录创建和修改的时间戳,由系统自动填充
      • @$_.foreign(obj):见表之间的关系
      • @$_.deprecated;指明该列已被废弃
  • 表之间的关系: 两个关系表中都有一个类字段指向对向表,该字段的类型为对向表的表名,如果含有命名空间,也需要命名空间前缀
    表之间的关系有一对一、一对多、多对多关系
    一对一、一对多关系需要在相应的外键字段指定@$_.foreign(obj),外键字段也会建立在该表中
    obj 写法:

    • 如果 obj 是一个字符串或字符串数组,则 obj 为外键名,默认引用主键表 id 类型的字段
    • 如果 obj 是一个对象,则有以下字段:
      • keys: 是一个字符串或字符串数组,其成员为外键名(aca.ts 会自动添加到数据库表中
      • references:是一个字符串或字符串数组,引用的主键表的 id 或 unique 字段名
      • onUpdate、onDelete:当主键被修改或删除时,外键表的动作,如果缺省,默认采用:.aca/config.json/databases 对应字段下的相应设置
    • 一对一关系:主键字段或外键字段至少有一个是可选字段(在字段名后面加问号(?))
    • 一对多关系:在主键表类型后面加数组类型(类型后面加方括号([])),此时外键字段自动为可选字段
    • 多对多关系:两个表的类型都为数组类型,此时,aca.ts 会自动创建一个多对多关系的映射表,对外不可见,如果需要可见,则你需要显式定义这个多对多关系映射表
      注意:当两个表之间有多个关系时,最多只有一组关系可以不用在类型中指定对象字段名,其余关系必须在类型中指定对向表的字段名

# 数据库 API

  • ts 类型:$Enum $TB $TbOper $ApiBridge

    • $Enum:所有自定义的枚举类型都集中放在这里,在代码中引用如下:$Enum['枚举名']

    • $TB:所有表字段的类型定义集中在这里,如果是关系类型,则为:$Relation<'关系表名', '关系表字段名', '关系类型'> 关系类型:toOne(对一关系)、toMany(对多关系)、M2M(多对多关系) 在代码中引用某个表的类型如下:$TB['顶级命名空间名_表名']

    • $TbOper:对表的操作类型定义,有:unique where insert update updateMany select selectScalar,使用如下: $TbOper<'顶级命名空间名_表名'>['where']

    • $ApiBridge:连接前端和后端的数据格式定义:

      export type $ApiBridge =
        | {
            kind: 'rpc'
            method: string[]
            args: any
          }
        | {
            kind: 'orm'
            query: string
            dbVar: string
            method: string[]
            args: any
          }
        | {
            kind: 'raw'
            dbVar: string
            args: any
          }
      
  • 数据库 API:对定义的所有 ORM,分别生成一个实例化的类对象,对象名为顶级命名空间名,为每个表生成处理方法,有:

    • findOne

    • findFirst

    • findMany

    • insert

    • upsert

    • update

    • updateMany

    • delete

    • deleteMany

    • 聚合函数:count countDistinct sum sumDistinct avg avgDistinct max mix,集中放在每个表的$命名空间中

    • raw:对每个数据库生成一个 raw 函数,用$.raw 表示
      访问方式:<顶级命名空间名>.<表名>.<方法>,每个方法的传参如下:

      // 这些方法和类型前端和后端一致
      type findOne<Tb extends keyof $TB> = {
        where: $TbOper<Tb>['unique']
        select?: $TbOper<Tb>['select']
        sql?: true
      }
      findFirst<Tb extends keyof $TB> = {
        where?: $TbOper<Tb>['where']
        select?: $TbOper<Tb>['select']
        sql?: true
        orderBy?: $Enumerable<{ [K in $ScalarColumns<Tb>]?: $Order }>
      }
      findMany<Tb extends keyof $TB> ={
        where?: $TbOper<Tb>['where']
        select?: $TbOper<Tb>['select']
        distinct?: '*' | $Enumerable<$ScalarColumns<Tb>>
        limit?: number
        offset?: number
        sql?: boolean
        orderBy?: $Enumerable<{ [K in $ScalarColumns<Tb>]?: $Order }>
      }
      insert<Tb extends keyof $TB> = {
        data: $TbOper<Tb>['insert'] | $TbOper<Tb>['insert'][]
        select?: $TbOper<Tb>['select']
        sql?: true
      }
      upsert<Tb extends keyof $TB> = {
        where: $UN[Tb]
        insert: $TbOper<Tb>['insert']
        update?: $TbOper<Tb>['update']
        select?: $TbOper<Tb>['select']
        sql?: true
      }
      update<Tb extends keyof $TB> = {
        where: $UN[Tb]
        data: $TbOper<Tb>['update']
        select?: $TbOper<Tb>['select']
        sql?: true
      }
      updateMany<Tb extends keyof $TB> = {
        where?: $TbOper<Tb>['where']
        data: $TbOper<Tb>['updateMany']
        select?: $TbOper<Tb>['selectScalar']
        sql?: true
      }
      delete<Tb extends keyof $TB> ={
        where: $UN[Tb]
        select?: $TbOper<Tb>['selectScalar']
        sql?: true
      }
      deleteMany<Tb extends keyof $TB> = {
        where: $TbOper<Tb>['where']
        select?: $TbOper<Tb>['selectScalar']
        sql?: true
      }
      // 聚合函数
      aggregate<Tb extends keyof $TB> = {
        select: {
          [K in keyof Required<$TB[Tb]>]?: number extends $TB[Tb][K] ? K : never
        }[keyof $TB[Tb]]
        where?: $TbOper<Tb>['where']
        sql?: true
      }
      
      
  • 事务:数据库事务使用如下:

  const trx = <顶级命名空间名>.transaction()
  // 注意: 一定要使用commit()和rollback()
  try {
    await trx.<表名>.<方法>
    await trx.commit()
  } catch(e){
    await trx.rollback()
  }

注意:前端也有事务,但并不能实现真正的事务,仅仅是为了使用事务的方式书写代码,便于以后将代码迁移到后端时,减少代码改动!

  • $ApiBridge:该方法是桥接前端传到后端的数据,会解析成前端相同的方法名和参数,再调用后端相同的方法,并将结果返回

# RPC 函数

RPC 函数定义在后端,需存放在 API 目录下的 RPC 文件夹(如:src/aca.server/RPC),其写法如:

export async function fn(ctx: any, param: string): stirng {
  return param
}

特别注意:

  1. 函数的第一个参数为上下文,在生成前端的同名函数时,会把这个参数省去
  2. 如果在 RPC 目录下有多个文件,则 aca.ts 会自动把 RPC 目录下的 index.ts 文件重写为其他页面的引用导出,此时不能将 RPC 函数写在 index.ts 中
  3. 支持命名空间

# 安全性

  • 前端数据库 API 只建议在开发阶段使用,在生产环境禁止使用
  • 生产环境访问后端的数据都必须经过严格的数据校验
  • 生产环境 web 访问使用 https
  • 带 token 访问后端
  • 生产环境禁止使用 raw 函数

# 开发建议

  • 在做 web 开发时,尽量将访问后端的方法集中在一个文件夹,不要直接在前端逻辑中嵌入调用数据库方法
  • 如果在一个逻辑需要多次访问后端数据库,尽量写成事务的方式,待代码稳定后,把代码移到后端,写成一个 RPC 函数,供前端调用
  • 前端调用后端的函数建议如下写法,迁移到后端时,代码改动最小:
// ctx为后端的上下文,前端可以定义一个变量模拟上下文
async function fn(param) {
  // 当这个函数迁移到后端成为RPC函数时
  // 取消下面这行注释,调用后端函数
  // return $RPC.fn(param)
  let ctx
  const trx = <数据库>.transaction()
  try {
    trx.<表名>.<方法名>()
    await trx.commit()
  } catch (e) {
    await trx.rollback()
  }
}
// 以上代码迁移到后端,做成RPC函数,改动如下(将ctx作为函数的入参):
async function fn(ctx, param) {
  const trx = <数据库>.transaction()
  try {
    trx.<表名>.<方法名>()
    await trx.commit()
  } catch (e) {
    await trx.rollback()
  }
}

# 交流群

代码及文档还处于内测阶段,欢迎提出宝贵意见,qq 群:638713641