SwiftletModel offers an easy and efficient way to implement complex domain models in your iOS applications.
- Entities as Plain Structs: Define your entities using simple Swift structs.
- Bidirectional Relations: Manage relationships between entities effortlessly with type safety.
- Normalized In-Memory Storage: Store your data in a normalized form to maintain consistency and efficiency.
- On-the-Fly Denormalization: Transform your data into any required shape instantly.
- Incomplete Data Handling: Seamlessly handle scenarios involving partial or missing data.
- Codable Out of the Box: Easily encode and decode your entities for persistence, response mapping, or other purposes.
SwiftletModel excels in the following scenarios:
- Complex Domain Models: Ideal for apps with intricate domain models that require a robust and flexible solution.
- Backend-Centric Applications: Perfect for applications where the backend is the primary source of truth for data management.
- Lightweight Local Storage: Suitable when you want to avoid the development overhead of persistent storage solutions like CoreData, SwiftData, Realm, or SQLite.
SwiftletModel offers a streamlined, in-memory alternative to CoreData and SwiftData. It is designed for applications that need a straightforward local data management system without the complexity of a full-fledged database.
Although primarily in-memory, SwiftletModel’s data model is Codable, allowing for straightforward data persistence if required.
- Getting Started
- How to Save Entities
- How to Delete Entities
- How to Query Entities
- Codable Conformance
- Relationship Types
- Establishing Relations
- Incomplete Data Handling
- Type Safety
- Installation
- Documentation
- Licensing
First, we define the model with all kinds of relations:
@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
}
That's pretty much it. EntityModel macro will generate all the necessary stuff to
make our model conform to EntityModelProtocol
requirements.
EntityModelProtocol definitions
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 }
}
Now let's create a chat instance and put some messages into it. To do it we need to create a context first:
var context = Context()
Now let's create a chat with some messages.
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"])
)
Now let's save chat to the context.
try chat.save(to: &context)
Just look at this.
Instead of providing the full entities everywhere...We need to provide them at least somewhere! In other cases, we can just put ids and it will be enough to establish proper relations.
At this point, our chat and the related entities will be saved to the context.
- All entities will be normalized so we don't have to care about duplication.
- Bidirectional links will be managed.
If your model has optional fields and contains incomplete data, you can save it as a fragment:
try chat.save(to: &context, options: .fragment)
It will patch the existing model. Read more about fragment data handling: Handling incomplete Entity data
The delete method is generated via EntityModel macro making deletion as simple as:
let chat = Chat("1")
chat.delete(from: &context)
Calling delete(...)
will
- remove the current instance from the context
- it will nullify all relations or cascade delete depending on
DeleteRule
attribute - call
willDelete(...)
anddidDelete(...)
callbacks when needed.
DeleteRule allows to specify how the related entities would be treated when current entity is deleted:
- nullify (the default option)
- cascade
@Relationship(deleteRule: .cascade, inverse: \.message)
var attachment: Attachment?
Let's query something. For example, a User with the following nested models:
It can be done with the following syntax:
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()
Wait but we've just saved a chat with users and messages. Now we are querying things from another end, WTF?
Exactly. That's the point of bidirectional links and normalization.
When resolve()
is called all entities are pulled from the context storage
and put in its place according to the nested shape in denormalized form.
We can also query related items directly:
let userChats: [Chat] = User
.query("1", in: context)
.related(\.$chats)
.resolve()
Since models are implemented as plain structs we can get Codable
out of the box:
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)
Here is the JSON string that we will get
{
"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 supports the following types of relations:
- One way & Mutual
- To One & To Many
- Optional & Required
All of them are represented by a single property wrapper: @Relationship
Here is a way to define an optional relation.
/**
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?
The optionality of the Relation means that it can be explicitly nullified. (See: Handling missing Data for to-one Relations)
/**
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)
There is a way to define an required relation.
/**
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?
If it is a required relation, why is the property still optional? Relation properties are always optional because it's the way how SwiftletModel handles incomplete data.
Required relation only means that it cannot be explicitly nullified.
To-many relationships can be defined the following way:
/**
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]?
Basically, it's required because there is no reason for to-many relations to have an explicit nil.
The properties themselves are read-only. However, relations can be set up through the property wrapper's 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 up exactly the same way:
/**
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)
To-many relations support not only setting up new relations,
but also appending new relations to the existing ones. It can be done via appending(...)
(See: Handling incomplete data for to-many Relations)
/**
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)
Saving an entity with all related ones is possible thanks to the Entity save method and is done automatically.
There are several options to remove relations.
/**
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 provides a few strategies to handle incomplete data for the cases:
- Incomplete Entity Models
- Incomplete Related Entity Models
- Incomplete collections of to-many Relations
- Missing Data for to-one Relations
When the service gets more mature, models often become bulky. We sometimes have to fetch them partially from different sources or deal with partial model data.
Let's define a user model with an optional 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]?
}
In SwiftletModel partial entity models are called fragments. SwiftletModel provides a reliable way
to deal with fragments via MergeStrategy
without corrupting existing data.
MergeStrategy defines how new entities are merged with existing ones that we already have in the Context.
/**
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 }
}
When saving entities to context, you can omit the options
since the defaultMergeStrategy is used.
The default merge Strategy replaces existing models in the context upon saving:
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)
Fragment merge strategy patches existing models in the context. In other words, it updates only non-nil values. It's automatically generated via macro so you don't have to do anything.
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)
Both default and fragment merge strategies can be overridden for any entity:
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)
)
}
}
Merge strategy may include several properties.
MergeStrategy(
.patch(\.name),
.patch(\.profile),
.patch(\.avatar)
)
Merge strategy can be applied to arrays.
MergeStrategy(
.append(\.arrayOfSomething)
)
You can write your own merge strategy for any type:
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
}
}
}
When assigning related nested entities, we can mark them as fragments to utilise fragment merging strategy:
var chat = Chat(id: "1")
chat.$users = .fragment([.bob, .alice, .john])
try! chat.save(to: &context)
We often have to deal with portions of data. If we have a collection of anything on the backend it will almost certainly be paginated.
SwiftletModel provides a convenient way to deal with incomplete collections for to-many relations.
When setting to-many relation it's possible to mark the collection as a appending slice. In that case, all the related entities will be appended to the existing ones.
/**
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)
To-one relation can be either optional or required.
Basically, data can be missing for at least 3 reasons:
-
The business logic of the app allows the related entity to be missing. For example: a message may not have an attachment.
-
Data is missing because we haven't loaded it yet. If the source is a backend or even a local storage there is almost certainly a case when the app hasn't received the data yet.
-
The logic of obtaining the data implies that some of the data will be missing. For example: a typical app flow where we obtain a list of chats from the backend. Then we get a list of messages for the chat. Even though a message cannot exist without a chat, a message model coming from the backend will hardly ever contain a chat model because it will make the shape of the data weird with a lot of duplication.
When we deal with missing data it's hard to figure out the reason why it's missing. It can always be an explicit nil or maybe not.
That's why SwiftletModel's relations properties are always optional. It allows to implement a patching update policy for relations by default: when entities with missing relations are saved to the storage they don't overwrite or nullify existing relations.
This allows to safely update models and merge them with the exising data:
/**
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)
Optional relation allows to set the relation to an explicit 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)
Relations rely heavily on principles of Type-Driven design under the hood. They are implemented so that there is very little chance of misuse.
struct Relation<Entity, Directionality, Cardinality, Constraints> { ... }
All you can do with relation is defined by its Directionality, Cardinality, and Constraint types.
Any mistake will be spotted at compile time:
- You cannot accidentally set an explicit nil to the required relation
- You cannot establish a wrong relation by making it mutual on one side and one-way on another
- You can't save relation in a wrong way
- You cannot ever confuse to-one and to-many relations
This also means that you cannot accidentally break it.
If you want to learn more about type-driven design here is a wonderful series of articles about it.
You can add SwiftletModel to an Xcode project as an SPM package:
- From the File menu, select Add Package Dependencies...
- Enter "https://github.com/KazaiMazai/SwiftletModel.git" into the package repository URL text field
- Profit
Full project documentation can be found here
SwiftletModel is licensed under MIT license.