【tauri开发】数据库上的另辟蹊径

前言

书接上回,偷偷摸鱼?当场逮捕

我使用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 }) {
       ...
    }
}

另外,为了使用时有类型提示下面进行一段类型推导🥳

  1. QueryBuilder都包含compile方法
  2. Transform接受model进行遍历,如果是方法就继续推导,属性则返回
  3. 推导方法,形参取Parameters<T[K]>,判断返回值是否是QueryBuilder,对model方法进行限制
  4. 如果是查询,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

相关推荐
四喜花露水2 分钟前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy11 分钟前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie41 分钟前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust1 小时前
css:基础
前端·css
帅帅哥的兜兜1 小时前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
yi碗汤园1 小时前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称1 小时前
购物车-多元素组合动画css
前端·css
编程一生1 小时前
回调数据丢了?
运维·服务器·前端
丶21362 小时前
【鉴权】深入了解 Cookie:Web 开发中的客户端存储小数据
前端·安全·web
Missmiaomiao3 小时前
npm install慢
前端·npm·node.js