Projection-based Database Persistence in Swift
PersistDB is alpha-quality software. It currently has a number of limitations.
-
Type-safety: Compile-time errors prevent runtime errors
-
Concurrency: It’s hard to block a thread without synchronous APIs
-
Consistency: Viewed data should always be internally consistent
-
Value Semantics: Data can be passed across threads, transformed, and easily tested
-
Typed Errors: Exhaustiveness checking prevents failures on the sad path
Traditional ORMs map a row from a SQL table directly onto an object. Each property on the object represents either a column in the table or a relationship.
PersistDB defines schemas like a traditional ORM. But data is fetched as a projection, much like a GraphQL query. This guarantees that the loaded data will be consistent.
Every operation—inserting, deleting, or changing data—can be represented by a value. This makes it possible to write code without side effects, making testing easy.
Please see the Examples directory to see what this looks like in practice. In particular, look at models, tests, and view controllers.
Schemas are defined using Swift types. These types are typically never instantiated, but are used to filter, sort, and query the database. They are often defined as final class
s so that Swift can construct memory layouts for one-to-one relationships.
final class Book {
let id: ID<Book>
let title: String
let author: Author
init(id: Int, title: String, author: Author) {
self.id = id
self.title = title
self.author = author
}
}
final class Author {
let id: ID<Author>
let name: String
let books: Set<Book>
init(id: Int, name: String, books: Set<Book>) {
self.id = id
self.name = name
self.books = books
}
}
Once you’ve made your types, you can declare them to be Model
s and construct the Schema
for the type. This is done in a type-safe way by using the type’s init
and Swift’s smart keypaths.
extension Book: PersistDB.Model {
static let schema = Schema(
Book.init,
\.id ~ "id", // The strings here are the names that the columns
\.title ~ "title", // will have in the database.
\.author ~ "author"
)
}
extension Author: PersistDB.Model {
static let schema = Schema(
Author.init,
\.id ~ "id"
\.name ~ "name",
\.books ~ \Book.author
)
}
Once you’ve made your models, you can create Projection
s, which are how you load information from the database. A projection resembles a view model: it has the data you actually want to present in a given context.
struct BookViewModel {
let title: String
let authorName: String
let authorBookCount: Int
}
extension BookViewModel: ModelProjection {
static let projection = Projection<Book, BookViewModel>(
BookViewModel.init,
\.title,
\.author.name,
\.author.books.count
)
}
The Store
is the interface to the database; it is the source of all side-effects. Creating a Store
is simple:
Store<ReadWrite>
.store(at: URL(…), for: [Book.self, Author.self])
.startWithResult { result in
switch result {
case let .success(store):
…
case let .failure(error):
print("Failed to load store: \(error)")
}
}
Stores can only be loaded asynchronously so the main thread can’t accidentally be blocked.
Sets of objects are fetched with Query
s, which use Predicate
s to filter the available models and SortDescriptor
s to sort them.
let harryPotter: Query<None, Book> = Book.all
.filter(\.author.name == "J.K. Rowling")
.sort(by: \.title)
Actual fetches are done with a Projection
.
store
// A `ReactiveSwift.SignalProducer` that fetches the data
.fetch(harryPotter)
// Do something the the array or error
.startWithResult { result in
switch result {
case let .success(resultSet):
…
case let .failure(error):
…
}
}
You can also observe the object(s) to receive updated values if changes are made:
store
.observe(harryPotter)
.startWithResult { result in
…
}
PersistDB provides Table
to help you build collection UIs. It includes built-in intelligent diffing to help you with incremental updates.
Inserts, updates, and deletes are all built on value types: Insert
, Update
, and Delete
. This makes it easy to test that your actions will have the right effect without trying to verify actual side effects.
Insert
and Update
are built on ValueSet
: a set of values that can be assigned to a model entity.
struct Task {
public let id: UUID
public let createdAt: Date
public var text: String
public var url: URL?
public static func newTask(text: String, url: URL? = nil) -> Insert<Task> {
return Insert([
\Task.id == .uuid(),
\Task.createdAt == .now,
\Task.text == text,
\Task.url == url,
])
}
}
store.insert(Task.newTask(text: "Ship!!!"))
PersistDB includes TestStore
, which makes it easy to test your inserts and updates against queries to verify that they’ve set the right properties.
The easiest way to add PersistDB to your project is with Carthage. Follow the instructions there.
PersistDB is available under the MIT license.