Fluent with SwiftUI
Apple’s CoreData is a great choice for a data model on iOS and with SwiftUI. Under the hood it uses SQLite
to handle lots of data that can’t fit into memory all at once. I have always wondered about using SQLite
directly. It would be a little messy because SQLite
is a C library and it would force you to use the unsafe APIs that you would certainly want to wrap. It turns out that you can pretty easily use the ORM from the Vapor project in your SwiftUI project. I prepared this example by looking at test code.
Let’s make this simple list app:
Start by creating a vanilla SwiftUI project and importing the fluent-sqlite-driver Swift package. File > Swift Packages and add https://github.com/vapor/fluent-sqlite-driver to the project.
Once that is done you can create a model type:
import FluentSQLiteDriver
final class Planet: Model {
static let schema = "planets"
@ID(key: .id) var id: UUID?
@Field(key: "name") var name: String
init() {}
init(id: UUID? = nil, name: String) {
self.id = id
self.name = name
}
}
You will want to create a migration to prepare the tables in the database:
struct CreatePlanetMigration: Migration {
func prepare(on database: Database) -> EventLoopFuture<Void> {
database.schema(Planet.schema)
.id()
.field("name", .string, .required)
.create()
}
func revert(on database: Database) -> EventLoopFuture<Void> {
database.schema(Planet.schema)
.delete()
}
}
For SwiftUI, you will want to creat a view-model:
import SwiftUI
final class AppModel: ObservableObject {
let databases: Databases
var logger: Logger = {
var logger = Logger(label: "database.logger")
logger.logLevel = .trace
return logger
}()
var database: Database {
return self.databases.database(
logger: logger,
on: self.databases.eventLoopGroup.next()
)!
}
// More to follow...
}
The databases keeps the databases available. (Just like core data there might multiple stores.)
Next add this to the AppModel
:
init() {
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let threadPool = NIOThreadPool(numberOfThreads: 1)
threadPool.start()
databases = Databases(threadPool: threadPool, on: eventLoopGroup)
databases.use(.sqlite(.memory), as: .sqlite)
databases.default(to: .sqlite)
do {
try CreatePlanetMigration().prepare(on: database).wait()
try ["Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn", "Uranus", "Ntune"].forEach {
let item = Planet(name: $0)
try item.save(on: database).wait()
}
}
catch {
print("error \(error)")
}
}
This creates an in-memory database and adds some records to it. It performs the work syncronously on the main thread, but this should happen fast so is probably okay. (Fluent is designed to scale to hundreds or even thousands of concurrent operations.)
If you want the database to use durable storage, you can use
.sqlite(.file(path))
instead of.sqlite(.memory)
Now let’s get the UI working. Finally add this to the AppModel
:
var planets: [Planet] {
try! database.query(Planet.self).all().wait()
}
This just syncronously grabs the data. Now create the SwiftUI to show it off.
@main
struct PlanetsApp: App {
@StateObject var model = AppModel()
var body: some Scene {
WindowGroup {
ContentView(model: model)
}
}
}
struct ContentView: View {
@ObservedObject var model: AppModel
var body: some View {
NavigationView {
List {
ForEach(model.planets, id: \.id) {
Text($0.name)
}
}.navigationTitle("Planets")
}
}
}
You can, of course, do more fancy queries using the Fluent ORM. For example:
var planets: [Planet] {
try! database.query(Planet.self).filter(\.$name ~~ "a").all().wait()
}
This filters down to all of the planets that contain an “a”. If you look at the console, you can see the SQL this boils down to:
database.logger : database-id=sqlite
SELECT "planets"."id" AS "planets_id",
"planets"."name" AS "planets_name" FROM "planets"
WHERE "planets"."name" LIKE ? ["%a%"]
That’s pretty cool. In a future post, we’ll look at how to interface with Combine better with non-blocking calls.