前言
书接上回,偷偷摸鱼?当场逮捕
我使用tauri(js + rust)做了一个应用,为了快速实现功能,数据库上并没有考虑太多,选择了官方的插件也就是直接写sql,简单方便。但是随着功能逐渐增加,直接操作sql就有点琐碎了,回想起用typeorm写crud的日子,一个orm框架是多么的方便
但想使用orm框架也只能望着远方的rust,凭我的三脚猫rust功力还是算了,作为一个前端开发当然得用js。在一番思索和漫长的搜索中,我找到了一个解,kysely
kysely 什么玩意?
The type-safe SQL query builder for TypeScript
kysely,简单来说可以保证类型正确的同时提供一套api对数据库的操作
下面简单介绍一下用法,以便后续说明
按照kysely的做法,先声明Database类型确定好所有表,这里选用sqlite
typescript
import {
Generated,
DummyDriver,
Kysely,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
} from 'kysely'
interface PersonTable {
id: Generated<number>
first_name: string
}
interface Database {
person: PersonTable
}
const db = new Kysely<Database>({
dialect: {
createAdapter: () => new SqliteAdapter(),
createDriver: () => new DummyDriver(),
createIntrospector: db => new SqliteIntrospector(db),
createQueryCompiler: () => new SqliteQueryCompiler(),
},
})
使用query builder虽然比直接写sql方便点,但还不够
非常关键的是kysely提供compile方法,compile将api转化为sql语句,这样就可以绕过kysely自带的执行逻辑,把sql传给自定义的执行器
typescript
const compiledQuery = db
.selectFrom('person')
.select('first_name')
.where('id', '=', id)
.compile()
console.log(compiledQuery) // { sql: 'select "first_name" from "person" where "id" = $1', parameters: [1], query: { ... } }
另辟蹊径
声明model
创建一个class,其中的方法返回由kysely构成builder(InsertQueryBuilder、UpdateQueryBuilder、DeleteQueryBuilder、SelectQueryBuilder)
typescript
class Person {
kysely: Kysely<Database>
insert(value: Insertable<PersonTable>) {
return this.kysely.insertInto('person').values(value)
}
update(id: number, value: Updateable<PersonTable>) {
return this.kysely.updateTable('person').set(value).where('id', '=', id)
}
remove(id: number) {
return this.kysely.deleteFrom('person').where('person.id', '=', id)
}
select(value?: { id?: number }) {
let query = this.kysely.selectFrom('person')
if (value?.id)
query = query.where('person.id', '=', value.id)
return query
}
}
再看看sql执行器的声明,查询使用select,而增、删、改使用execute
query和bindValues则对应compiledQuery的sql和parameters
typescript
export interface QueryResult {
rowsAffected: number;
lastInsertId: number;
}
interface DatabaseExecutor {
execute(query: string, bindValues?: unknown[]): Promise<QueryResult>;
select<T>(query: string, bindValues?: unknown[]): Promise<T>;
}
下面3行代码完成了查询操作,但过于繁琐,需要包装一层
typescript
const person = new Person()
const { sql, parameters } = person.select().compile()
executor.select(sql, parameters)
动态赋值+ts类型声明
传入models,使用proxy监听每个model的函数调用,这样当调用db.person.select()时会去调用model person上的select,返回的SelectQueryBuilder经过compile后传给执行器,最后返回结果
typescript
function createKyselyDatabase<U extends Record<string, object>>(executor: DatabaseExecutor, models: U) {
class KyselyDatabase<M extends Record<string, object>> {
#executor: DatabaseExecutor
constructor(executor: DatabaseExecutor, models: M) {
this.#executor = executor
for (const modelKey in models) {
const obj = {
[modelKey]: new Proxy(models[modelKey], {
get: (target, p) => {
if (typeof target[p] === 'function') {
return (...args) => {
const fn = target[p]
const { __getFlag } = fn as any
const query = fn.apply(target, args)
const CompiledQuery = query.compile()
if (__getFlag) {
return this.#select(CompiledQuery)
}
return this.#execute(CompiledQuery)
}
}
else {
return target[p]
}
},
}),
}
Object.assign(this, obj)
}
}
#execute<T extends CompiledQuery>(query: T) {
return this.#executor.execute(query.sql, query.parameters as unknown[])
}
#select<T extends CompiledQuery>(query: T) {
return this.#executor.select<InferResult<T>>(query.sql, query.parameters as unknown[])
}
}
return new KyselyDatabase(executor, models) as KyselyDatabase<U> & Executor<U>
}
const models = {
person
}
const db = createKyselyDatabase(executor, models)
这里其实是有一个问题,虽然监听了方法执行,其实我们无法区分哪个方法调用是查询操作
这里使用装饰器,在方法上设置了__getFlag标识进行标注
diff
function get(target, propertyKey, descriptor) {
descriptor.value.__getFlag = true
}
class Person {
+ @get
select(value?: { id?: number }) {
...
}
}
另外,为了使用时有类型提示下面进行一段类型推导🥳
- QueryBuilder都包含compile方法
- Transform接受model进行遍历,如果是方法就继续推导,属性则返回
- 推导方法,形参取Parameters<T[K]>,判断返回值是否是QueryBuilder,对model方法进行限制
- 如果是查询,kysely提供InferResult类型对compile返回值进行最终推导,其余操作则返回QueryResult
typescript
type IsSelectQueryBuilder<T> = T extends SelectQueryBuilder<any, any, any> ? true : false
type IsQueryBuilder<T> = T extends {
compile(): any
} ? true : false
type Transform<T> =
{
[K in keyof T]:
T[K] extends (...args: any) => any
? (...args: Parameters<T[K]>) =>
IsQueryBuilder<ReturnType<T[K]>> extends true
?
Promise<
IsSelectQueryBuilder<ReturnType<T[K]>> extends true
? InferResult<ReturnType<ReturnType<T[K]>['compile']>>
: QueryResult
>
: never
: T[K]
}
type Executor<U = typeof models> = { [K in keyof U]: Transform<U[K]> }
这样就可以方便调用的同时享受到ts带来的类型推导
长路漫漫
不安分的入参
就当我以为大功告成时,有一段代码出现在了我的脑海,在增、改、查操作时某个字段在代码和数据库中并不是同样的类型
举个例子,我要存储一个number[],但存在数据库的时候要转成string,从数据库读取的时候也要转化一道。我需要在set和get时对字段进行统一处理,那就需要在class赋上一个处理对象
typescript
export function injectModel<M, V>(options?: {
set?: (value: Partial<V>) => Partial<M>
get?: (model: M) => Partial<V>
}) {
return (target) => {
target.__transform = options
}
}
@injectModel<OriginProgram, TransformProgram>({
set: v => ({
icon: v.icon?.join(','),
}),
get: m => ({
icon: m.icon.split(',').map(Number),
}),
})
export class Program {
...
}
处理从数据库查询的结果简单,只需对select进行处理就行了,增、改的时候就有点麻烦。在获取入参时我们不知道要处理的参数在args的位置,好在有装饰器
diff
export function set(target, propertyKey, parameterIndex) {
target[propertyKey].__setIndex = parameterIndex
}
- insert(value: Insertable<PersonTable>) {
+ insert(@set value: Insertable<PersonTable>) {
return this.kysely.insertInto('person').values(value)
}
- update(id: number, value: Updateable<PersonTable>) {
+ update(id: number, @set value: Updateable<PersonTable>) {
return this.kysely.updateTable('person').set(value).where('id', '=', id)
}
回到database的执行逻辑中,__transform.set和__setIndex转化入参,__transform.get转化结果
diff
return (...args) => {
const fn = target[p]
- const { __getFlag } = fn as any
+ const { __setIndex, __getFlag } = fn as any
+ if (__transform && __transform.set && typeof __setIndex == 'number')
+ Object.assign(args[__setIndex], __transform.set(args[__setIndex]))
const query = fn.apply(target, args)
const CompiledQuery = query.compile()
if (__getFlag) {
- return this.#select(CompiledQuery)
+ return this.#select(CompiledQuery).then(result => {
+ if (__transform && __transform.get)
+ return result.map(i => Object.assign(i, __transform.get(i)))
+ else
+ return result
+ })
}
return this.#execute(CompiledQuery)
}
纠缠的关系
相信用过orm框架的读者都熟知框架提供一套relation来声明不同表直接的关系,一对一、一对多、多对多
假设两张表program和activity,program有多个activity,在查询数据activity时需要同时查出对应的program,kysely提供了jsonObjectFrom工具函数,让多个model组合在一起
typescript
class Activity {
table = 'activity'
#program: Program
constructor(kysely: Kysely<Database>, program: Program) {
this.kysely = kysely
this.#program = program
}
@get
select() {
return this.kysely.selectFrom(this.table).select(
jsonObjectFrom(
this.#program.select().whereRef('activity.programId', '=', 'program.id'),
).as('program'),
).selectAll(this.table)
}
}
但返回的program属性其实是字符串,需要在执行器select获取数据后进行JSON.parse,然而事情并没有这么简单,如果包含的关系同时也与其他model进行关联,就需要递归去给每一层进行parse
重新修改injectModel,加上relation确保知道返回的数据哪个字段需要进行处理
diff
export function injectModel<E, V>(options?: {
set?: (value: Partial<V>) => Partial<E>
get?: (entity: E) => Partial<V>
+ relation?: { [K in TableName]?: object }
}) {
return (target) => {
target.__transform = options
}
}
function transformResult(constructor, obj) {
const { __transform } = constructor
if (!__transform)
return obj
if (__transform.get)
Object.assign(obj, __transform.get(obj))
for (const key in __transform.relation) {
obj[key] = JSON.parse(obj[key])
transformResult(__transform.relation[key], obj[key])
}
return obj
}
return this.#select(CompiledQuery).then(result => {
- if (__transform && __transform.get)
- return result.map(i => Object.assign(i, __transform.get(i)))
+ if (__transform)
+ return result.map(i => transformResult(target.constructor as any, i))
else
return result
})
遗漏的事务
当我以为应该告一段落了,一个问题又挡在了我的面前,如果在model里我要执行多个sql语句该怎么办?凭借我有限的数据库开发知识,我还是在大脑的角落里找到了事务
虽然我完全没有用过事务,但看了看kysely的示例和代码,大概知道事务的提交 和回滚
现在的model都是方法返回一个QueryBuilder然后compile后执行,同样地我声明一个TransactionQueryBuilder保持相同接口就行了。其他的QueryBuilder返回CompiledQuery,而TransactionQueryBuilder返回一个回调,参数trx就是Database,这样就可以直接访问其他model的方法
typescript
export class TransactionBuilder<T> {
execute<U>(callback: (trx: T) => Promise<U>) {
return new TransactionQueryBuilder<T, U>(callback)
}
}
export class TransactionQueryBuilder<T, U> {
constructor(private callback: (trx: T) => Promise<U>) {}
compile() {
return this.callback
}
}
class Program {
table = 'program' as const
transaction() {
return new TransactionBuilder<Executor>()
}
removeRelation(id: number) {
return this.transaction().execute(async (trx) => {
await trx.program.remove(id)
await trx.activity.removeBy({
programId: id,
})
})
}
}
KyselyDatabase添加执行事务的方法,model返回经过compile传入executeTransaction,trx参数对应this,执行错误则进行回滚
diff
class KyselyDatabase {
...
constructor() {
...
const query = fn.apply(target, args)
const CompiledQuery = query.compile()
+ if (query instanceof TransactionQueryBuilder)
+ return this.#executeTransaction(CompiledQuery as any)
}
async #executeTransaction(callback: (trx) => Promise<unknown>) {
try {
await this.#executor.execute('begin')
const result = await callback(this)
await this.#executor.execute('commit')
return result
}
catch (error) {
await this.#executor.execute('rollback')
}
}
}
与此同时,回调的返回值通过泛型U传递给了TransactionQueryBuilder,这样在调用事务时也能享受到ts的类型推导
diff
+ type IsTransactionQueryBuilder<T> = T extends TransactionQueryBuilder<any, any> ? true : false
type Transform<T> =
{
[K in keyof T]:
T[K] extends (...args: any) => any
? (...args: Parameters<T[K]>) =>
IsQueryBuilder<ReturnType<T[K]>> extends true
?
Promise<
IsSelectQueryBuilder<ReturnType<T[K]>> extends true
? InferResult<ReturnType<ReturnType<T[K]>['compile']>>
- : QueryResult
+ : IsTransactionQueryBuilder<ReturnType<T[K]>> extends true
+ ? Awaited<ReturnType<ReturnType<ReturnType<T[K]>['compile']>>>
+ : QueryResult
>
: never
: T[K]
}
测试把关
单元测试、集成测试相信各位都听过不少,但工作上我从来没写过,小公司没有这种要求,写完能跑交给测试慢慢测就完事了
本着写开源项目尝试一下的态度,我决定还是给这个简陋的小屋搭上一个棚子
但一开始就犯了难,现在数据库的模块是这样的,model声明完,database compile执行完传给executor,而这个executor是tauri的插件,本质上是前端运行了传给rust处理。如何启动一个无窗口的tauri来进行测试,这个念头把我带入了误区,经过一番搜索...,自然是一无所获
不知过了多久,灵光咋现,一个念头冲破迷雾,好似拨云见日,脑内顿时风起云涌
我一个前端为啥一定要把数据传给rust来运行,我直接跑nodejs不是一样的,只要nodejs这边的executor能和tauri的插件工作一致不就行了,它们都是对数据库的封装差别能到哪去
typescript
// better-sqlite3
const executor: DatabaseExecutor = {
execute(query, bindValues) {
const { changes, lastInsertRowid } = sqlite.prepare(query).run(...(bindValues || []))
return Promise.resolve<QueryResult>({
lastInsertId: lastInsertRowid as number,
rowsAffected: changes,
})
},
select(query, bindValues) {
return Promise.resolve<any>(sqlite.prepare(query).all(...(bindValues || [])))
},
}
// sqlx(tauri-plugin-sql-api)
const executor: DatabaseExecutor = {
execute(...args) {
return database.execute(...args)
},
select(...args) {
return database.select(...args)
},
}
完整的开头
到这里整个数据库的封装、执行、测试都已经讲完了,但是不是还少了点什么?
回想一下orm框架第一步会做什么?model对数据库的映射,虽然现在已经声明了model,但我并没有做对数据库上的处理
讲了一大堆,到现在数据库表都还没建,但别急,不会耽误你太多时间的
回忆一下kysely最开始需要声明的Database类型,手动去声明每个table。这未免太过于麻烦了,好在提供了prisma-kysely,它可以将prisma的schema转化为需要的类型
这个prisma和之前提到的typeorm一样是orm框架,但这里我们只需要借助它的声明 和迁移功能
kotlin
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
}
最后
经过千辛万苦终于结束了,但现在还没有任何数据库错误处理的逻辑,在KyselyDatabase的execute和executeTransaction中其实都会抛出错误,目前来自executor的handleError并没有完成,因为better-sqlite3和sqlx返回的错误真的有些许不同,日后再进行完善😎
查看完整项目,见链接
画图使用工具excalidraw