Sucha's Blog ~ Archive for November, 2024

24年11月30日 周六 10:57

SwiftSQLiteORM

地址在 https://github.com/lalawue/SwiftSQLiteORM.git

之前在公司实现过一个 Swift SQLite 的 ORM 库,基于 GRDB,ORM 的优势就是方便,不用自己 CURD,也能享受到关系数据库的 ACID(原子性、一致性、完整性与持久性)能力。

只不过 ORM 的方式,搜索能力确实一般而已,比如不好自己组 SQL 搜索子句,以及 join 其他表,或者仅 select 输出某几列。

不过这个输出某几列没有实现的原因,倒不是不好组子句,只是 Swift 是有类型系统的,输出的额外的类型必须得先定义,这个其实跟已有的表结构可能完全不相同,所以省略不做的。

因为之前自己也重构过 Lua 的 SQLite 的 ORM,所以多少熟悉了这个部分(没办法,工作很大一部分就是熟能生巧,在限制场景和条件下找解决方案,一边摸索一边实践,顺利的话,条件满足后就可以输出,当然纯逻辑的的数学、理论物理等这些领域不熟悉,不发表意见)。

使用介绍

先给个定义的例子:

    // nested struct will store as JSON string
    struct ExampleNested: Codable {
        let desc: String
        let index: Int
    }

    struct ExampleType: DBTableDef {
        let name: String
        let data: ExampleNested

        typealias ORMKey = Columns

        /// keep blank or return nil for using hidden 'rowid' column
        static var primaryKey: Columns? {
            return .name
        }

        enum Columns: String, DBTableKey {
            case name
            case data
        }
    }

ExampleType 是需要支持 ORM 的结构,遵循 DBTableDef,可以是 struct 或者是 class,不要求遵循 Codable 协议。

如上也就定义了数据库中的表结构,其中使用 DBTableKey 定义了数据库中的字段类型(支持仅部分属性进入数据库),这里还额外定义了 primary key 是 name,这样就支持了后续的 deletes([T]) 的接口,可以直接传递需要删除的实例进去。

如果结构有嵌套的子结构,比如上面的 ExampleNested,则需要支持 Codable 协议,因为非内建字段类型 ,默认是 Blob,将 encode 为 JSON 后保存,否则将 throw error。

使用如下:

    do {
        // insert / update
        let c = ExampleType(name: "c", data: ExampleNested(desc: "c", index: 1))
        let u = ExampleType(name: "u", data: ExampleNested(desc: "u", index: 2))
        try DBMgnt.push([c, u])

        // select
        let arr = try DBMgnt.fetch(ExampleType.self, .eq(.name, c.name))
        XCTAssert(arr.count == 1, "failed")
        XCTAssert(arr[0].name == c.name, "failed")

        // delete
        try DBMgnt.deletes([c]) // require primaryKey in table definition
        try DBMgnt.delete(ExampleType.self, .eq(.name, u.name))
        let count = try DBMgnt.fetch(ExampleType.self, .eq(.name, c.name)).count
        XCTAssert(count == 0, "failed")

        // clear
        try DBMgnt.clear(ExampleType.self)

        // drop table
        try DBMgnt.drop(ExampleType.self)

    } catch {
        if let err = error as? DBORMError {
            fatalError("failed to try block: \(err.localizedDescription)")
        } else {
            fatalError("failed to try block: \(error.localizedDescription)")
        }
    }

CURD 基本上就是 push、fetch、deletes、delete、clear、drop 这样,其中 fetch、delete 接口支持 DBRecordFilter,关联 DBTableDef 中的字段定义,计算操作、比较操作都是可以传递属性名称进去的,类型则遵循 SQLite select 的类型转换规定,我大体理解是跟字段定义相关,比如 SQLite 给的字段定义可以是 TEXT、INTEGER、REAL、BLOB。

另外,我只实现了部分内建字段的映射定义,用户可以根据自己需要通过 DBPrimitive 协议,定义某些类型支持映射:

/// database column type
/// - will perform relative type calculation in sql expression
/// - https://sqlite.org/datatype3.html
public enum DBStoreType {

    /// Int64
    case INTEGER

    /// Double
    case REAL

    /// String, Numeric
    case TEXT

    /// Data
    case BLOB
}

/// database store value type
/// - will sotre as type's DatabaseValueConvertible through GRDB
public enum DBStoreValue {

    /// box with int64
    case integer(Int64)

    /// box with double
    case real(Double)

    /// box with String
    case text(String)

    /// box with data
    case blob(Data)
}

/// type transform for store / restore from database
public protocol DBPrimitive: DefaultConstructor {

    /// database column type
    static var ormStoreType: DBStoreType { get }

    /// return TypeInfo for mocking, for example objc wrapper NSUUID
    static func ormTypeInfo() throws -> TypeInfo

    /// mapping value to store in database
    func ormToStoreValue() -> DBStoreValue?

    /// restore value from database
    static func ormFromStoreValue(_ value: DBStoreValue) -> Self?
}

其中的 DBStoreType 是数据库列类型,DBStoreValue 则是将自定义字段的数据通过 String、Int64、Double 或者 Data 传递到数据库,以及读取的时候,数据库也将返回这几种数据,给自定义类型来恢复。

比如最开始例子的 name 属性是 String 类型,将映射为 SQLite 下的 TEXT 列类型。

比如下面例子的 URL 将映射为 TEXT 列类型:

extension URL: DBPrimitive {

    public init() {
        // will be placed by database value later
        self.init(string: "a://a.a")!
    }

    public static var ormStoreType: SwiftSQLiteORM.DBStoreType { .TEXT }

    public func ormToStoreValue() -> SwiftSQLiteORM.DBStoreValue? {
        return .text(self.absoluteString)
    }

    public static func ormFromStoreValue(_ value: SwiftSQLiteORM.DBStoreValue) -> URL? {
        guard case .text(let string) = value else {
            return nil
        }
        return URL(string: string)
    }
}

上面的例子都没有涉及到如何 connect 数据库,以及建立 table 等,因为这些 ORM 库都自动做了,甚至支持 alter table。

比如需要多增加一个属性,并记录到数据库,只需要升级 DBTableDef 下的 tableVersion 就好,是一个 Double 类型的字段。

当然还支持指定 table 名称,以及使用独立的数据库文件,如下:

extension DBTableDef {

    /// ...

    /// specify table name or use type name
    /// - should be unique in all scope
    static var tableName: String { get }

    /// schema version for table columns, default 0
    /// - increase version after you add columns
    static var tableVersion: Double { get }

    /// specify database file name or use default
    static var databaseName: String { get }

    /// ...
}

实现细节

ORM 需要将 struct、class 的指定字段,映射到 SQLite 中的表结构,对应列的类型需要能保存属性数据,便于后续的计算、比较操作,这里对 Swift 语法结构的获取,用的库是 Runtime,这个也是我在 github 上遇到了 SQLiteORM 后,读取其代码了解到的。

从 Runtime 这个库,可以拿到 Swift 结构的属性列表,以及每一个属性的类型,甚至可以构造这个属性的实例。

比如 fetch 接口操作之后,这个实例的字段数据,是依赖数据库字段读取到的,因为 DBTableDef 支持仅保存部分字段,因此其他字段可以通过下面的函数由用户自己填充,或者使用默认值。

/// Table ORM mapping definition
public protocol DBTableDef {

    /// ...

    /// update instance property value created by type reflection
    /// - only ORMKey covered property can restore value from database column
    /// - others property will use default value
    static func ormUpdateNew(_ value: inout Self) -> Self
}

Swift 侧的表结构大致可以定义,但是数据库这边我偷懒了,不像 SQLiteORM 那样通过 SQLite 的接口组织操作,而是利用了 GRDB 来做保存、读取。

这次我用了 GRDB.swift/SQLCipher 来做底层存储操作接口,其中的加密用的 password phrase 是库自动生成后,保存到 keychain 里面的。所以每次重新安装后,password phrase 都会不一样,但不影响使用,用户侧也无需知道具体是什么。

GRDB 的读取是通过 Row 结构,使用上类似字典,保存的时候是通过 encode,也是类似字典,将属性保存到 container 中:

    /// insert / update
    override func encode(to container: inout PersistenceContainer) {
        /// ...
    }

其中遇到的一些问题,以及不大好解决的问题记录大概有:

SQLite 不支持保存 UInt64,所以这个类型,我是保存为了 TEXT,记录、恢复当然是没有问题的,只是在计算、比较的时候,我大概理解 SQLite 应该是 cast 为了 TEXT 来进行比较的吧。对比之下,GRDB 几乎是不支持 UInt64。

NSDecimalNumber 其实在 Swift 里面就是 Decimal,但在 ObjC 里面,是 NSNumber 的子类,在 GRDB 里面,是被认为是 NSNumber,然后内部比较后来保存的,GRDB 对于 NSDecimalNumber 仅支持保存 >=Int64.min 和 <=Int64.max 的值。

而在本库 NSDecimalNumber 是作为 TEXT 保存,短板也是类似 UInt64。

同样的 NSNumber 本库也是作为 TEXT 保存,估计计算、比较的时候会有点问题吧,但基本存取操作在边界上是没啥问题的。

等于是将计算、比较任务,都扔给 SQLite 列类型来处理了,比如 Decimal 跟 Real 的比较,等于是 SQLite 中 TEXT 字段和输入 REAL 值的比较了。

也许这部分后续可以改进一下,利用 GRDB 的 argument 参数,使用 SQLite 的 bind 接口来完成,估计会更好一些,后面再说吧。

大概就是这样。