# 开发指南

# 关于 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

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

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

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

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

# 数据库 API

  • ts 类型:$Enum $ApiBridge

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

    • $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 where 条件支持 id 字段或 unique 字段

      // By unique identifier
      const user = await blog.user.findOne({
        where: {
          firstName: 'tom',
          lastName: 'aca'
        }
      })
      // By ID
      const user = await blog.user.findOne({
        where: {
          id: 99
        }
      })
      
    • findMany 筛选条件和运算符

      • eq 值等于 n。
      • not 值不等于 n。
      • in 值 n 存在于列表中。
      • lt 值 n 小于 x。
      • lte 值 n 小于或等于 x。
      • gt 值 n 大于 x。
      • gte 值 n 大于或等于 x。
      • contains 值 n 包含 x。
      • like 值 n 包含 x。
      • startsWith 值 n 以 x 开头。
      • endsWith 值 n 以 x 结尾。
      • AND 所有条件必须返回 true。或者,将对象列表传递到 where 子句中 - AND 运算符不是必需的。
      • OR 一个或多个条件必须返回 true。
      • 关系过滤器
        • some 返回所有 一个或多个(“some”)相关记录与筛选条件匹配的记录。
        • every 返回所有满足以下条件的记录:所有(“每个”)相关的记录都匹配过滤条件。
        • none 返回所有满足以下条件的记录:零个相关记录匹配过滤条件。
      // returns all User records:
      const users = await blog.user.findMany({})
      // Filter by a single field value
      const users = await blog.user.findMany({
        where: {
          lastName: {
            endsWith: 'aca'
          }
        }
      })
      // Filter by multiple field values
      const users = await blog.user.findMany({
        where: {
          firstName: {
            startsWith: 't'
          },
          age: {
            lt: 18
          },
          OR: {
            age: {
              gt: 18
            },
            AND: {
              gender: Gender.Male
            }
          }
        },
        orderBy: {
          id: 'desc'
        },
        limit: 100,
        offset: 0
      })
      // Filter by related record field values
      const users = await blog.user.findMany({
        where: {
          lastName: {
            endsWith: 'aca'
          },
          posts: {
            some: {
              score: {
                gt: 5
              }
            },
            every: {
              score: {
                lte: 50
              }
            }
          }
        }
      })
      const posts = await blog.post.findMany({
        where: {
          author: {
            lastName: 'aca',
            age: {
              gt: 40
            }
          }
        }
      })
      
    • findFirst 筛选条件和运算符同 findMany

      // Filter by a single field value
      const user = await blog.user.findFirst({
        where: {
          lastName: {
            endsWith: 'aca'
          }
        }
      })
      // Filter by multiple field values
      const user = await blog.user.findFirst({
        where: {
          firstName: {
            startsWith: 't'
          },
          OR: {
            age: {
              gt: 18
            },
            AND: {
              gender: {
                equals: Gender.Male
              }
            }
          }
        },
        orderBy: {
          id: 'desc'
        }
      })
      // Filter by related record field values
      const user = await blog.user.findFirst({
        where: {
          lastName: {
            endsWith: 'aca'
          },
          posts: {
            some: {
              score: {
                gt: 5
              }
            },
            every: {
              score: {
                lte: 50
              }
            }
          }
        }
      })
      const post = await blog.post.findFirst({
        where: {
          author: {
            lastName: 'aca',
            age: {
              gt: 40
            }
          }
        }
      })
      
    • insert

      • 创建单个记录

        const user = await blog.user.insert({
          data: {
            firstName: 'tom',
            lastName: 'aca'
          }
        })
        // 用户的 id 是自动生成的
        
      • 创建多个记录

        const users = await blog.user.insert({
          data: [
            {
              firstName: 'tom',
              lastName: 'aca'
            },
            {
              firstName: 'yao',
              lastName: 'min'
            }
          ]
        })
        
      • 创建冲突处理

        const users = await blog.user.insert({
          data: [
            {
              firstName: 'tom',
              lastName: 'aca'
            },
            {
              firstName: 'yao',
              lastName: 'min'
            }
          ],
          skipConflict: {
            onConflict: ['firstName', 'lastName'],
            // action: 'merge'
            action: 'ignore'
          }
        })
        
      • 嵌套写入

        • 嵌套写入允许您在单个事务中将关系数据写入数据库。
        • 为在单个查询中跨多个表创建、更新或删除数据提供事务性保证。如果查询的任何部分失败(例如,创建用户成功,但创建帖子失败),将回滚所有更改。
        • 支持数据模型支持的任何嵌套级别。
        • 当使用模型的 insert 或 update 查询时,关联字段可用。以下部分展示了每个查询可用的嵌套写入选项。

        您可以同时创建一条记录和一个或多个关联记录。 以下查询创建一条 User 记录和两条关联的 Post 记录

        const user = await blog.user.insert({
          data: {
            firstName: 'tom',
            lastName: 'aca',
            posts: {
              insert: [
                {
                  content: 'content1',
                  score: 5,
                  categories: {
                    insert: {
                      name: 'category'
                    }
                  }
                },
                { content: 'content2', score: 10 }
              ]
            }
          }
        })
        

        连接单条记录

        const post = await blog.post.insert({
          data: {
            content: 'content1',
            score: 5,
            author: {
              connect: {
                firstName: 'tom',
                lastName: 'aca'
              }
            }
          }
        })
        

        连接多条记录 以下查询创建一个新的 User 记录,并将该记录 (connect) 连接到两个现有文章

        const user = await blog.user.insert({
          data: {
            firstName: 'tom',
            lastName: 'aca',
            posts: {
              connect: [{ serial: '2' }, { serial: '3' }]
            }
          }
        })
        

        连接或创建记录 如果关联记录可能存在也可能不存在,请使用 connectOrInsert 来连接关联记录

        const post = await blog.post.insert({
          data: {
            content: 'content1',
            score: 5,
            author: {
              connectOrInsert: {
                connect: {
                  firstName: 'tom',
                  lastName: 'aca'
                },
                insert: {
                  firstName: 'tom',
                  lastName: 'aca',
                  age: 20
                }
              }
            }
          }
        })
        
    • update

      • 更新单个记录 where 条件支持 id 字段或 unique 字段
        const user = await blog.user.update({
          where: {
            firstName: 'tom',
            lastName: 'aca'
          },
          data: {
            age: 5,
            lastName: 'aca2'
          }
        })
        
      • 嵌套更新
        • insert
          const user = await blog.user.update({
            where: {
              firstName: 'tom',
              lastName: 'aca'
            },
            data: {
              age: 5,
              lastName: 'aca2',
              posts: {
                insert: {
                  content: 'content1',
                  score: 5,
                  categories: {
                    insert: {
                      name: 'category'
                    }
                  }
                }
              }
            }
          })
          
        • connect
          const user = await blog.user.update({
            where: {
              firstName: 'tom',
              lastName: 'aca'
            },
            data: {
              age: 5,
              lastName: 'aca2',
              posts: {
                connect: {
                  serial: '3'
                }
              }
            }
          })
          
        • connectOrInsert
          const post = await blog.post.update({
            where: {
              serial: '2'
            },
            data: {
              author: {
                connectOrInsert: {
                  connect: {
                    firstName: 'tom',
                    lastName: 'aca'
                  },
                  insert: {
                    firstName: 'tom',
                    lastName: 'aca',
                    age: 18
                  }
                }
              }
            }
          })
          
        • disconnect
          const user = await blog.user.update({
            where: {
              id: '2'
            },
            data: {
              posts: {
                disconnect: {
                  serial: '2'
                }
              }
            }
          })
          
        • set
          const user = await blog.user.update({
            where: {
              id: '2'
            },
            data: {
              posts: {
                set: {
                  serial: '2'
                }
              }
            }
          })
          
        • delete
          const user = await blog.user.update({
            where: {
              id: '2'
            },
            data: {
              posts: {
                delete: {
                  serial: '2'
                }
              }
            }
          })
          
        • deleteMany
          const user = await blog.user.update({
            where: {
              id: '2'
            },
            data: {
              posts: {
                deleteMany: {
                  score: {
                    lt: 5
                  }
                }
              }
            }
          })
          
        • update
          const user = await blog.user.update({
            where: {
              id: '2'
            },
            data: {
              posts: {
                update: {
                  where: {
                    serial: '4'
                  },
                  data: {
                    score: 15
                  }
                }
              }
            }
          })
          
        • updateMany
          const user = await blog.user.update({
            where: {
              id: '2'
            },
            data: {
              posts: {
                updateMany: {
                  where: {
                    score: {
                      lt: 10
                    }
                  },
                  data: {
                    score: 15
                  }
                }
              }
            }
          })
          
    • updateMany 更新多个记录

      const updateUsers = await blog.user.updateMany({
        where: {
          firstName: {
            contains: 'tom'
          }
        },
        data: {
          age: 18
        }
      })
      
    • upsert 更新或创建记录 以下查询使用 upsert() 更新具有特定姓名的 User 记录,如果该 User 记录不存在,则创建该记录

      const upsertUser = await blog.user.updateMany({
        where: {
          firstName: 'tom',
          lastName: 'aca'
        },
        insert: {
          firstName: 'tom',
          lastName: 'aca',
          age: 18
        },
        update: {
          age: 18
        }
      })
      
    • delete

      const deleteUser = await blog.user.delete({
        where: {
          firstName: 'tom',
          lastName: 'aca'
        }
      })
      
    • deleteMany

      const deleteUsers = await blog.user.deleteMany({
        where: {
          firstName: {
            contains: 'tom'
          }
        }
      })
      
    • 聚合函数:count countDistinct sum sumDistinct avg avgDistinct max mix,集中放在每个表的$命名空间中

    • raw:对每个数据库生成一个 raw 函数,用$.raw 表示

    • select

      • 默认情况下,当查询返回记录(而不是计数)时,结果包括模型的所有标量字段(包括枚举)
        const users = await blog.user.findMany({})
        const users = await blog.user.findMany({
          select: {
            '*': true
          }
        })
        
      • 选择特定字段
        const user = await blog.user.findFirst({
          where: {
            firstName: {
              contains: 'tom'
            }
          },
          select: {
            age: true,
            firstName: true
          }
        })
        
      • 通过选择关系字段返回嵌套对象 你可以任意深度地嵌套查询。
        const users = await blog.user.findFirst({
          select: {
            firstName: true,
            lastName: true,
            posts: {
              content: true,
              score: true
            }
          }
        })
        const users = await blog.user.findFirst({
          select: {
            firstName: true,
            lastName: true,
            posts: {
              where: {
                score: {
                  lt: 5
                }
              },
              orderBy: {
                score: 'desc'
              },
              select: {
                content: true,
                score: true
              }
            }
          }
        })
        
    • omit 排除特定字段

        const user = await blog.user.findFirst({
          where: {
            firstName: {
              contains: 'tom'
            }
          },
          omit: {
            age: true,
            firstName: 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