Skip to content

Swift 模型绑定

qiuwenchen edited this page Mar 7, 2024 · 19 revisions

模型绑定(Object-relational Mapping,简称 ORM),通过对 Swift 类或结构进行绑定,形成类或结构 - 表模型类或结构对象 - 表的映射关系,从而达到通过对象直接操作数据库的目的。

WCDB Swift 的模型绑定分为五个部分:

  • 字段映射
  • 字段约束
  • 索引
  • 表约束
  • 虚拟表映射

这其中大部分是格式化的模版代码,我们在最后介绍文件模版和代码提示模版,以简化模型绑定的操作。

字段映射

WCDB Swift 的字段映射基于 Swift 4.0 的 Codable 协议实现。以下是一个字段映射的示例代码:

class Sample: TableCodable {
    var identifier: Int? = nil
    var description: String? = nil
    var offset: Int = 0
    var debugDescription: String? = nil
        
    enum CodingKeys: String, CodingTableKey {
        typealias Root = Sample
        static let objectRelationalMapping = TableBinding(CodingKeys.self)
        case identifier = "id"
        case description
        case offset = "db_offset"
    }
}
  1. 在类内定义 CodingKeys 的枚举类,并遵循 StringCodingTableKey
  2. 枚举列举每一个需要定义的字段。
  3. 对于变量名与表的字段名不一样的情况,可以使用别名进行映射,如 case identifier = "id"
  4. 对于不需要写入数据库的字段,则不需要在 CodingKeys 内定义,如 debugDescription
  5. 对于变量名与 SQLite 的保留关键字冲突的字段,同样可以使用别名进行映射,如 offset 是 SQLite 的关键字。

与 Swift 的 Codable 协议不同的是,即便是所有字段都需要绑定,这里也必须列举每一个需要绑定的字段。因为 CodingKeys 除了用于模型绑定,还将用于语言集成查询,我们会在后面章节中介绍。

字段映射定义完成后,调用 create(table:of:) 接口即可根据这个定义创建表。

// 以下代码等效于 SQL:CREATE TABLE IF NOT EXISTS sampleTable(id INTEGER, description TEXT, db_offset INTEGER)
try database.create(table: "sampleTable", of: Sample.self)

字段映射的类型

并非所有类型的变量都支持被绑定为字段。WCDB Swift 内建了常用类型的支持,包括:

数据库中的类型 类型
32 位整型 Bool, Int, Int8, Int16, Int32, UInt, UInt8, UInt16, UInt32
64 位整型 Int64, UInt64, Date
浮点型 Float, Double
字符串类型 String, URL
二进制类型 Data, Array, Dictionary, Set

其中 Date 以时间戳的形式存储, ArrayDictionarySet 以 JSON 的形式存储。

对于没有内建支持的类型,开发者可以手动为其添加支持。我们将在自定义字段映射类型一章进一步介绍。

字段约束

字段约束是 TableEncodable 的一个可选函数,可根据需求选择实现或不实现。它用于定于针对单个字段的约束,如主键约束、非空约束、唯一约束、默认值等。

以下是一个字段约束的示例代码:

class Sample: TableCodable {
    var identifier: Int? = nil
    var description: String? = nil
        
    enum CodingKeys: String, CodingTableKey {
        typealias Root = Sample
        case identifier
        case description
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
            BindColumnConstraint(identifier, isPrimary: true)
            BindColumnConstraint(description, isNotNull: true, defaultTo: "defaultDescription")
        }
    }

    var isAutoIncrement: Bool = false // 用于定义是否使用自增的方式插入
    var lastInsertedRowID: Int64 = 0 // 用于获取自增插入后的主键值
}

字段约束通过 BindColumnConstraint 配置,可以定义每个 CodingKeys 对应的约束。BindColumnConstraint 的声明如下:

BindColumnConstraint(
  	_ codingKey: CodingTableKeyType,// 对应字段的枚举
    isPrimary: Bool = false, // 该字段是否为主键。字段约束中只能同时存在一个主键
    orderBy term: OrderTerm? = nil, // 当该字段是主键时,存储顺序是升序还是降序
    isAutoIncrement: Bool = false, // 当该字段是主键时,其是否支持自增。只有整型数据可以定义为自增。
    onConflict conflict: Conflict? = nil, // 当该字段是主键时,若产生冲突,应如何处理
    isNotNull: Bool = false, // 该字段是否可以为空
    isUnique: Bool = false, // 该字段是否可以具有唯一性
    defaultTo defaultValue: ColumnDef.DefaultType? = nil // 该字段在数据库内使用什么默认值
)

以上约束按需进行定义或者不定义即可。 定义完成后,同样调用 create(table:of:) 接口即可根据这个定义创建表。

// 以下代码等效于 SQL:CREATE TABLE IF NOT EXISTS sampleTable(identifier INTEGER PRIMARY KEY, description TEXT NOT NULL DEFAULT 'defaultDescription')
try database.create(table: "sampleTable", of: Sample.self)

自增属性

配置了 isPrimaryisAutoIncrement的字段,支持以自增的方式进行插入数据。但仍可以通过非自增的方式插入数据。

当需要进行自增插入时,对象需设置 isAutoIncrement 参数为 true,则数据库会使用 已有数据中最大的值+1 作为主键的值。

let autoIncrementObject = Sample()
autoIncrementObject.isAutoIncrement = true

// 插入自增数据
try database.insert(objects: autoIncrementObject, intoTable: "sampleTable")
print(autoIncrementObject.lastInsertedRowID) // 输出 1

// 再次插入自增数据
try database.insert(objects: autoIncrementObject, intoTable: "sampleTable")
print(autoIncrementObject.lastInsertedRowID) // 输出 2

// 插入非自增的指定数据
let specificObject = Sample()
specificObject.identifier = 10
try database.insert(objects: specificObject, intoTable: "sampleTable")

对于自增插入的数据,可以在类内定义 lastInsertedRowID 字段,并以此获取插入的值。

若类只会使用自增的方式插入,而不需要指定值的方式插入,可以在定义时直接设置 isAutoIncrementtrue。如:var isAutoIncrement: Bool { return true }

索引

索引可以通过BindIndex来配置,它用于定于针对单个或多个字段的索引,索引后的数据在能有更高的查询效率。

以下是一个定义索引的示例代码:

class Sample: TableCodable {
    var identifier: Int? = nil
    var description: String? = nil
    var multiIndexPart1: Int = 0
    var multiIndexPart2: Int = 0
        
    enum CodingKeys: String, CodingTableKey {
        typealias Root = Sample
        case identifier
        case description
        case multiIndexPart1
        case multiIndexPart2
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
            BindIndex(identifier, namedWith: "_uniqueIndex", isUnique: true)
            BindIndex(description.asIndex(orderBy: .descending), namedWith: "_descendingIndex")
            BindIndex(multiIndexPart1, multiIndexPart2.asIndex(orderBy: .ascending), namedWith: "_multiIndex")
        }
    }
}

索引通过索引后缀与索引绑定的映射实现。

  1. 对于需要特别指明索引存储顺序的字段,可以通过 asIndex(orderBy:) 函数指定,如 description.asIndex(orderBy: .descending)
  2. 对于具有唯一性的索引,可以通过 isUnique: 参数指定。
  3. 对于由多个字段组成的联合索引,BindIndex后面可以指定多个字段,如 BindIndex(multiIndexPart1, multiIndexPart2.asIndex(orderBy: .ascending), namedWith: "_multiIndex")

完整的索引名为表名+索引后缀,如:表 "sampleTable" 的索引分别为 "sampleTable_uniqueIndex"、"sampleTable_descendingIndex" 和 "sampleTable_multiIndex"。

索引定义完成后,同样调用 create(table:of:) 接口即可根据这个定义创建表。

// 以下代码等效于 SQL:
// CREATE TABLE IF NOT EXISTS sampleTable(identifier INTEGER, description TEXT, multiIndexPart1 INTEGER, multiIndexPart2 INTEGER)
// CREATE UNIQUE INDEX IF NOT EXISTS sampleTable_uniqueIndex on sampleTable(identifier)
// CREATE INDEX IF NOT EXISTS sampleTable_descendingIndex on sampleTable(description DESC)
// CREATE INDEX IF NOT EXISTS sampleTable_multiIndex on sampleTable(multiIndexPart1, multiIndexPart2 ASC)
try database.create(table: "sampleTable", of: Sample.self)

表约束

表约束可以下面这些方法来配置:

  1. BindMultiPrimary: 联合主键约束
  2. BindMultiUnique: 联合唯一约束
  3. BindChecks: 检查约束
  4. BindForeignKey: 外键约束

以下是一个表约束的示例代码:

class Sample: TableCodable {
    var identifier: Int? = nil
    var multiPrimaryKeyPart1: Int = 0
    var multiPrimaryKeyPart2: Int = 0
    var multiUniquePart1: Int = 0
    var multiUniquePart2: Int = 0
        
    enum CodingKeys: String, CodingTableKey {
        typealias Root = Sample
        case identifier
        case multiPrimaryKeyPart1
        case multiPrimaryKeyPart2
        case multiUniquePart1
        case multiUniquePart2
        
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
            BindMultiPrimary(multiPrimaryKeyPart1.asIndex(orderBy: .descending), multiPrimaryKeyPart2)
            BindMultiUnique(multiUniquePart1, multiUniquePart2.asIndex(orderBy: .ascending))
        }
    }
}

约束的定义方式与索引类似。定义完成后,同样调用 create(table:of:) 接口即可根据这个定义创建表。

// 以下代码等效于 SQL:
//  CREATE TABLE IF NOT EXISTS sampleTable(
//      identifier INTEGER, 
//      multiPrimaryKeyPart1 INTEGER, 
//      multiPrimaryKeyPart2 INTEGER, 
//      multiUniquePart1 INTEGER, 
//      multiUniquePart1 INTEGER,
//      CONSTRAINT PRIMARY KEY(multiPrimaryKeyPart1 DESC, multiPrimaryKeyPart2),
//      CONSTRAINT UNIQUE(multiUniquePart1, multiUniquePart2 ASC)
//  )
try database.create(table: "sampleTable", of: Sample.self)

虚拟表映射

虚拟表映射可以使用来BindVirtualTable来配置,它用于定于虚拟表以进行全文搜索等特性。

普通表不需要用到虚拟表映射,因此这里暂且按下不表,我们会在全文搜索一章中进行介绍。

数据库升级

在开发过程中,经过多个版本的迭代后,经常会出现数据库字段升级的情况,如增加新字段、删除或重命名旧字段、新增索引等等。 对于 SQLite 本身,其并不支持对字段的删除和重命名。新增加字段则需要考虑不同版本升级等情况。而这个问题通过模型绑定可以很好的解决。

纵观上述字段映射、字段约束、索引和表约束等四个部分,都是通过调用 create(table:of:) 接口使其生效的。 实际上,该接口会将 模型绑定的定义 与 表本身的结构 联系起来,并进行更新。

对于字段映射:

  1. 表已存在但模型绑定中未定义的字段,会被忽略。这可以用于删除字段。
  2. 表不存在但模型绑定中有定义的字段,会被新增到表中。这可以用于新增字段。
  3. 对于需要重命名的字段,可以通过别名的方式重新映射。

忽略字段并不会删除字段。对于该字段旧内容,会持续存在在表中,因此文件不会因此变小。实际上,数据库作为持续增长的二进制文件,只有将其数据导出生成另一个新的数据库,才有可能回收这个字段占用的空间。对于新插入的数据,该字段内容为空,不会对性能产生可见的影响。

对于索引,不存在的索引会被新增到数据库中。

对于数据库已存在但模型绑定中未定义的索引,create(table:of:) 接口不会自动将其删除。如果需要删除,开发者需要调用 drop(index:) 接口。

以下是数据库升级的一个例子:

在第一个版本中,Sample 的模型绑定定义如下,并在数据库创建了以之对应的表 sampleTable。

class Sample: TableCodable {
    var identifier: Int? = nil
    var description: String? = nil
    var createDate: Date? = nil
    
    enum CodingKeys: String, CodingTableKey {
        typealias Root = Sample
        case identifier
        case description
        case createDate
        static let objectRelationalMapping = TableBinding(CodingKeys.self)
    }
}

try database.create(table: "sampleTable", of: Sample.self)

到了第二个版本,sampleTable 表进行了升级。

class Sample: TableCodable {
    var identifier: Int? = nil
    var content: String? = nil
    var title: String? = nil
    
    enum CodingKeys: String, CodingTableKey {
        typealias Root = Sample
        case identifier
        case content = "description"
        case title
        
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
            BindIndex(title, namedWith: "_index")
        }
    }
}

try database.create(table: "sampleTable", of: Sample.self)

可以看到,通过修改模型绑定,并再次调用 create(table:of:)

  1. description 字段通过别名的特性,被重命名为了 content
  2. 已删除的 createDate 字段会被忽略。
  3. 对于新增的 title 会被添加到表中。
  4. 新增的索引sampleTable_index会被添加到表中。

文件与代码模版

模型绑定的大部分都是格式固定的代码,因此,WCDB Swift 提供了文件模版和代码模版两种方式,以简化模型绑定操作。 文件和代码模版都在源代码的 tools/templates 目录下

  • 未获取 WCDB 的 Github 仓库的开发者,可以在命令执行 curl https://raw.githubusercontent.com/Tencent/wcdb/master/tools/templates/install.sh -s | sh
  • 已获取 WCDB 的 Github 仓库的开发者,可以手动执行 cd path-to-your-wcdb-dir/tools/templates; sh install.sh;

文件模版

文件模版安装完成后,在 Xcode 的菜单 File -> New -> File... 中创建新文件,选择 TableCodable。 在弹出的菜单中输入文件名,并选择 Language 为 Swift 即可。 TableCodableXctemplate

代码模版

在代码文件中的任意位置,输入 TableCodableClass 后选择代码模版即可。 TableCodableSnippet

文件和代码模版都是以 class 作为例子的,实际上 struct 甚至 enum 都可以进行模型绑定的。

Clone this wiki locally