CI

SwiftletModel 提供了一种简单高效的方式,可以在 iOS 应用程序中实现复杂的领域模型。

为什么选择 SwiftletModel?

SwiftletModel 在以下场景中表现出色:

SwiftletModel 提供了一种简化的内存替代方案,可替代 CoreData 和 SwiftData。 它专为需要简单的本地数据管理系统而无需完整数据库复杂性的应用程序而设计。

虽然主要在内存中运行,但 SwiftletModel 的数据模型是 Codable,如果需要,可以进行简单的数据持久化。

目录

开始使用

模型定义

首先,我们定义具有各种关系的模型

@EntityModel
struct Message {
    let id: String
    let text: String
    
    @Relationship(.required)
    var author: User?
    
    @Relationship(.required, inverse: \.messages)
    var chat: Chat?
    
    @Relationship(inverse: \.message)
    var attachment: Attachment?
    
    @Relationship(inverse: \.replyTo)
    var replies: [Message]?
    
    @Relationship(inverse: \.replies)
    var replyTo: Message?
    
    @Relationship
    var viewedBy: [User]? = nil
}

差不多就是这样。 EntityModel 宏将生成所有必要的内容,使我们的模型符合 EntityModelProtocol 的要求。

EntityModelProtocol 定义

protocol EntityModelProtocol {
    // swiftlint:disable:next type_name
    associatedtype ID: Hashable, Codable, LosslessStringConvertible

    var id: ID { get }

    func save(to context: inout Context, options: MergeStrategy<Self>) throws

    func willSave(to context: inout Context) throws

    func didSave(to context: inout Context) throws

    func delete(from context: inout Context) throws

    func willDelete(from context: inout Context) throws

    func didDelete(from context: inout Context) throws

    mutating func normalize()

    static func batchQuery(in context: Context) -> [Query<Self>]

    static var defaultMergeStrategy: MergeStrategy<Self> { get }

    static var fragmentMergeStrategy: MergeStrategy<Self> { get }

    static var patch: MergeStrategy<Self> { get }
}

如何保存实体

现在让我们创建一个聊天实例并将一些消息放入其中。 为此,我们需要首先创建一个上下文

var context = Context()

现在让我们创建一个带有某些消息的聊天。

let chat = Chat(
    id: "1",
    users: .relation([
        User(id: "1", name: "Bob"),
        User(id: "2", name: "Alice")
    ]),
    messages: .relation([
        Message(
            id: "1",
            text: "Any thoughts on SwiftletModel?",
            author: .id( "1")
        ),
        
        Message(
            id: "1",
            text: "Yes.",
            author: .id( "2")
        )
    ]),
    admins: .ids(["1"])
)

现在让我们将聊天保存到上下文中。

try chat.save(to: &context)

看看这个。

与其在任何地方都提供完整的实体……我们至少需要在某个地方提供它们! 在其他情况下,我们只需放置 ID,就足以建立适当的关系。

此时,我们的聊天和相关实体将被保存到上下文中。

如果您的模型具有可选字段并包含不完整的数据,则可以将其另存为片段

try chat.save(to: &context, options: .fragment)

它将修补现有的模型。 阅读有关片段数据处理的更多信息: 处理不完整的实体数据

如何删除实体

删除方法是通过 EntityModel 宏生成的,从而使删除变得非常简单

let chat = Chat("1")
chat.delete(from: &context)

调用 delete(...) 将会

关系删除规则

DeleteRule 允许指定在删除当前实体时如何处理相关实体

@Relationship(deleteRule: .cascade, inverse: \.message)
var attachment: Attachment?

如何查询实体

使用嵌套模型查询

让我们查询一些东西。 例如,一个具有以下嵌套模型的用户

可以使用以下语法完成

let user = User
    .query("1", in: context)
    .with(\.$chats) { chat in
        chat.with(\.$messages) { message in
            message.with(\.$replies) { reply in
                reply.with(\.$author)
                     .id(\.$replyTo)
            }
            .id(\.$author)
            .id(\.$chat)
        }
        .with(\.$users)
        .id(\.$admins)
    }
    .resolve()

等等,但我们刚刚保存了一个包含用户和消息的聊天。 现在我们从另一端查询东西,这是怎么回事?

没错。 这就是双向链接和规范化的意义所在。

当调用 resolve() 时,所有实体都会从上下文存储中拉出,并根据反规范化形式的嵌套形状放置在其位置。

相关模型查询

我们还可以直接查询相关项目

let userChats: [Chat] = User
    .query("1", in: context)
    .related(\.$chats)
    .resolve()
    

Codable 一致性

由于模型是作为普通结构体实现的,因此我们可以开箱即用地获得 Codable

extension User: Codable { }

extension Chat: Codable { }

extension Message: Codable { }

/** 
And then use it for our codable purposes:
*/

let encoder = JSONEncoder.prettyPrinting
encoder.relationEncodingStrategy = .plain
let userJSON = user.prettyDescription(with: encoder) ?? ""
print(userJSON)
这是我们将获得的 JSON 字符串

{
  "adminOf" : null,
  "chats" : [
    {
      "admins" : [
        {
          "id" : "1"
        }
      ],
      "id" : "1",
      "messages" : [
        {
          "attachment" : null,
          "author" : {
            "id" : "1"
          },
          "chat" : {
            "id" : "1"
          },
          "id" : "1",
          "replies" : [
            {
              "attachment" : null,
              "author" : {
                "adminOf" : null,
                "chats" : null,
                "id" : "2",
                "name" : "Alice"
              },
              "chat" : null,
              "id" : "2",
              "replies" : null,
              "replyTo" : {
                "id" : "1"
              },
              "text" : "Yes.",
              "viewedBy" : null
            }
          ],
          "replyTo" : null,
          "text" : "Any thoughts on SwiftletModel?",
          "viewedBy" : null
        },
        {
          "attachment" : null,
          "author" : {
            "id" : "2"
          },
          "chat" : {
            "id" : "1"
          },
          "id" : "2",
          "replies" : [

          ],
          "replyTo" : null,
          "text" : "Yes.",
          "viewedBy" : null
        }
      ],
      "users" : [
        {
          "adminOf" : null,
          "chats" : null,
          "id" : "1",
          "name" : "Bob"
        },
        {
          "adminOf" : null,
          "chats" : null,
          "id" : "2",
          "name" : "Alice"
        }
      ]
    }
  ],
  "id" : "1",
  "name" : "Bob"
}

关系类型

SwiftletModel 支持以下类型的关系

所有这些都由一个属性包装器表示:@Relationship

可选的一对一关系

这是一种定义可选关系的方法。

/**
It can be either one way. 
*/

@Relationship
var user: User? = nil


/**
Or can be mutual. Mutual relation requires providing an inverse key pathsa as a witness to ensure 
that it is indeed mutual
*/

@Relationship(inverse: \.message)
var attachment: Attachment?

关系的可选性意味着可以显式地将其设置为 null。(参见:处理一对一关系的缺失数据

/**
When this message is saved, it **will nullify**
the existing message-attachment relation in the context.
*/

let message = Message(
    id: "1",
    text: "Any thoughts on SwiftletModel?",
    author: .id( "1"),
    attachment: .null
)


try message.save(to: &context)

必需的一对一关系

有一种方法可以定义一个必需的关系。

/**
It can be either one way: 
*/

@Relationship(.required)
var author: User?
    

/**
It can be mutual. Mutual relation requires providing a witness to ensure that it is indeed mutual: direct and inverse key paths.
Inverse relations can be either to-one or to-many and must be mutual.
*/

@Relationship(.required, inverse: \.messages)
var chat: Chat?

如果它是必需的关系,为什么该属性仍然是可选的? 关系属性始终是可选的,因为这是 SwiftletModel 处理不完整数据的方式。

必需关系仅表示它不能被显式地设置为 null。

一对多关系

可以通过以下方式定义一对多关系

/**
It can be either one way:
*/

@Relationship
var viewedBy: [User]? = nil

 
/**
It can also be mutual. Mutual relation requires to provide an inverse key path as a witness 
to ensure that it is really mutual.
*/

@Relationship(inverse: \.replyTo)
var replies: [Message]?

基本上,它是必需的,因为对于一对多关系而言,没有理由显式地具有 nil。

建立关系

这些属性本身是只读的。 但是,可以通过属性包装器的 projected values 来设置关系。

设置一对一关系

var message = Message(
    id: "1",
    text: "Howdy!"
)

/**
For to-one relations, we can attach by directly setting the relation:
*/
message.$author = .relation(user)
try message.save(to: &context)

/**
We can also attach by id. In that case 
we need to make sure that both entities exist in the context
*/
 
message.$author = .id( user.id)
try message.save(to: &context)
try user.save(to: &context)

/**
If the relation is optional we can nullify it by setting it to explicit nil. 
In that case, the existing relation will be destroyed on save. 
However, if there was some related entity it will not be deleted. 
*/
 
message.$attachment = .null
try message.save(to: &context)


/**
Setting the relation to `none` will not have any effect on the stored data. 
This happens automatically during normalization when you save an entity:
*/
 
message.$attachment = .none
try message.save(to: &context)

设置一对多关系

可以完全相同的方式设置一对多关系

/**
To-many relations can be set directly by providing an array of entities.
*/
chat.$messages = .relation([message])
try chat.save(to: &context)

/**
An array of ids will also work, but all entities should be additionally saved to the context. 
*/
chat.$messages = .ids([message.id])
try chat.save(to: &context)
try message.save(to: &context)       

一对多关系不仅支持设置新关系,还支持将新关系附加到现有关系。 这可以通过 appending(...) 完成

(参见:处理一对多关系的不完整数据

/**
New to-many relations can be appended to the existing ones when set as an appending slice:
*/
chat.$messages = .appending(relation: [message])
try chat.save(to: &context)

/**
An array of ids will also work, 
but all entities should be additionally saved to the context. 
*/
chat.$messages = .appending(ids: [message.id])
try chat.save(to: &context)
try message.save(to: &context)    

保存关系

由于 Entity save 方法,可以保存一个具有所有相关实体的实体,并且会自动完成。

移除关系

有几种删除关系的选项。

/**
We can detach entities. This will only destroy the relation between them while keeping entities in storage.
*/
detach(\.$chats, inverse: \.$users, in: &context)


/**
We can delete related entities. It only destroys the relationship between them.  The related entities will be also removed from storage with their `delete(...)` method.
*/
try delete(\.$attachment, inverse: \.$message, from: &context)


/**
We can explicitly nullify the relation. This is an equivalent of `detach(...)`
*/
message.$attachment = .null
try message.save(to: &context)

不完整数据处理

SwiftletModel 提供了一些策略来处理以下情况下的不完整数据

处理不完整的实体模型

当服务变得更加成熟时,模型通常会变得庞大。 有时我们必须从不同的来源部分地获取它们,或者处理部分模型数据。

让我们定义一个带有可选 Profile 的用户模型。

extension User {
    /**
    Something heavy here is that the backend does not serve for all requests.
    */
    struct Profile: Codable { ... }
}
 
@EntityModel 
struct User: Codable {
    let id: String
    private(set) var name: String
    private(set) var avatar: Avatar
    private(set) var profile: Profile?
    
    @Relationship(inverse: \.users)
    var chats: [Chat]?
    
    @Relationship(inverse: \.admins)
    var adminOf: [Chat]?
}
 

在 SwiftletModel 中,部分实体模型称为片段。 SwiftletModel 提供了一种可靠的方法,可以通过 MergeStrategy 处理片段,而不会破坏现有数据。

MergeStrategy 定义了如何将新实体与我们已在上下文中拥有的现有实体合并。

/**
To handle that `EntityModelProtocol` has a default and fragment merge strategies:
*/

public extension EntityModelProtocol {
    static var defaultMergeStrategy: MergeStrategy<Self> { .replace }
    
    static var fragmentMergeStrategy: MergeStrategy<Self> { Self.patch }
}

默认合并策略

将实体保存到上下文时,您可以省略 options,因为会使用 defaultMergeStrategy。

默认合并策略会在保存时替换上下文中现有的模型

var context = Context()

/**
This is a complete user entity having all properties set:
*/
let user = User(
    id: "1", 
    name: "Bob", 
    avatar: Avatar(...), 
    profile: User.Profile(...)
)

try save(to: &context)

片段合并策略

片段合并策略会修补上下文中现有的模型。 换句话说,它仅更新非 nil 值。 它是通过宏自动生成的,因此您无需执行任何操作。

var context = Context()

/**
This is a fragment. It doesn't a profile. 
Probably for a reason, we don't know, but we have to deal with it.
*/
let user = User(
    id: "1", 
    name: "Bob", 
    avatar: Avatar(...)
)

try save(to: &context, options: .fragment)

高级合并策略

可以为任何实体覆盖默认合并策略和片段合并策略

extension User {
    /**
    This will make patching as the default behavior:
    */
    static var defaultMergeStrategy: MergeStrategy<Self> {
        User.patch
    }
    
    /**
    This is what the `User.patch` strategy for the user 
    with an optional `profile` actually looks like: 
    */
    static var fragmentMergeStrategy: MergeStrategy<Self> { 
        MergeStrategy(
            .patch(\.profile)
        )
     }
}

合并策略可以包含多个属性。

MergeStrategy(
    .patch(\.name),
    .patch(\.profile),
    .patch(\.avatar)
)

合并策略可以应用于数组。

MergeStrategy(
    .append(\.arrayOfSomething)
)

您可以为任何类型编写自己的合并策略

extension MergeStrategy {
    /**
    This is what the property patch MergeStrategy looks like.
    */
    static func patch<Entity, Value>(_ keyPath: WritableKeyPath<Entity, Optional<Value>>) -> MergeStrategy<Entity>   {
        MergeStrategy<Entity> { old, new in
            var new = new
            new[keyPath: keyPath] = new[keyPath: keyPath] ?? old[keyPath: keyPath]
            return new
        }
    }
}

处理不完整的相关实体模型

在分配相关的嵌套实体时,我们可以将它们标记为片段,以利用片段合并策略

var chat = Chat(id: "1")
chat.$users = .fragment([.bob, .alice, .john])
try! chat.save(to: &context)

处理一对多关系的不完整数据

我们经常需要处理部分数据。 如果我们在后端有任何集合,几乎可以肯定的是它会被分页。

SwiftletModel 提供了一种方便的方式来处理一对多关系的不完整集合。

在设置一对多关系时,可以将集合标记为附加切片。 在这种情况下,所有相关实体都将附加到现有实体。

/**
New to-many relations can be appended 
to the existing ones when we set them as a appending entities:
*/
chat.$messages = .appending(relation: [message])
try chat.save(to: &context)

/**
or appending ids:
*/
chat.$messages = .appending(ids: [message])
try chat.save(to: &context)

/**
or appending fragments to ulitise fragment merging strategy:
*/ 
chat.$messages = .appending(fragment: [message])
try chat.save(to: &context)

处理一对一关系的缺失数据

一对一关系可以是可选的或必需的。

基本上,数据缺失的原因至少有 3 个

  1. 应用程序的业务逻辑允许缺少相关实体。 例如:消息可能没有附件。

  2. 缺少数据是因为我们尚未加载它。 如果源是后端甚至是本地存储,那么几乎可以肯定的是应用程序尚未收到数据。

  3. 获取数据的逻辑意味着某些数据将丢失。 例如:一个典型的应用程序流程,我们从后端获取聊天列表。 然后我们获取聊天的消息列表。 即使没有聊天消息就无法存在,但来自后端的聊天消息模型几乎永远不会包含聊天模型,因为它会使数据形状变得怪异并且存在大量重复。

当我们处理缺失数据时,很难弄清楚其缺失的原因。 它始终可以是显式的 nil,或者可能不是。

这就是为什么 SwiftletModel 的关系属性始终是可选的。 它允许默认情况下为关系实现修补更新策略:当具有缺失关系的实体保存到存储时,它们不会覆盖或使现有关系无效。

这允许安全地更新模型并将其与现有数据合并

/**
When this message is saved it **WILL NOT OVERWRITE** 
existing relations to attachments if there are any:
*/
let message = Message(
    id: "1",
    text: "Any thoughts on SwiftletModel?",
    author: .id( "1"),
)

try message.save(to: &context)

可选关系允许将关系设置为显式的 nil:

/**
When a message with an explicit nil 
is saved it **WILL OVERWRITE** existing relations to the attachment by nullifying them:
*/
let message = Message(
    id: "1",
    text: "Any thoughts on SwiftletModel?",
    author: .id( "1"),
    attachment: .null
)


try message.save(to: &context)

类型安全

关系在底层严重依赖于类型驱动设计的原则。 它们的实现方式使得误用的可能性很小。

struct Relation<Entity, Directionality, Cardinality, Constraints> { ... }

您可以使用关系执行的所有操作都由其方向性、基数和约束类型定义。

任何错误都会在编译时发现

这也意味着您不会意外地破坏它。

如果您想了解更多关于类型驱动设计的信息,这里有一系列精彩的文章介绍。

安装

您可以将 SwiftletModel 作为 SPM 包添加到 Xcode 项目中

文档

完整的项目文档可以在这里找到

许可

SwiftletModel 使用 MIT 许可证。