Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor the SwiftUI demo app #953

Merged
merged 1 commit into from
Apr 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,24 @@
/* Begin PBXBuildFile section */
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */; };
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */; };
5671722A261A185300423B6F /* Query.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717229261A185300423B6F /* Query.swift */; };
5671723A261B23C800423B6F /* PlayerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717239261B23C800423B6F /* PlayerList.swift */; };
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717251261B334D00423B6F /* PlayerRequestTests.swift */; };
567C3E1A2520B6DE0011F6E9 /* GRDBCombineDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */; };
567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */; };
567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */; };
567C3E4E2520B70E0011F6E9 /* GRDB.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 567C3E3B2520B7000011F6E9 /* GRDB.framework */; };
567C3E4F2520B70E0011F6E9 /* GRDB.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 567C3E3B2520B7000011F6E9 /* GRDB.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E532520B75C0011F6E9 /* Player.swift */; };
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E542520B75C0011F6E9 /* Persistence.swift */; };
567C3E5F2520B75C0011F6E9 /* PlayerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */; };
567C3E602520B75C0011F6E9 /* PlayerFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */; };
567C3E612520B75D0011F6E9 /* PlayerForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E592520B75C0011F6E9 /* PlayerForm.swift */; };
567C3E622520B75D0011F6E9 /* PlayerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5A2520B75C0011F6E9 /* PlayerList.swift */; };
567C3E632520B75D0011F6E9 /* PlayerCreationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5B2520B75C0011F6E9 /* PlayerCreationSheet.swift */; };
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */; };
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5A2520B75C0011F6E9 /* AppView.swift */; };
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */; };
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */; };
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E652520B7880011F6E9 /* AppDatabase.swift */; };
567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E752520BB650011F6E9 /* Localizable.stringsdict */; };
567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */; };
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -119,6 +121,9 @@
56026C9C25B8A7D000D1DF3F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTests.swift; sourceTree = "<group>"; };
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabaseTests.swift; sourceTree = "<group>"; };
56717229261A185300423B6F /* Query.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Query.swift; sourceTree = "<group>"; };
56717239261B23C800423B6F /* PlayerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = "<group>"; };
56717251261B334D00423B6F /* PlayerRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequestTests.swift; sourceTree = "<group>"; };
567C3E162520B6DE0011F6E9 /* GRDBCombineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GRDBCombineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBCombineDemoApp.swift; sourceTree = "<group>"; };
567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand All @@ -127,15 +132,14 @@
567C3E292520B7000011F6E9 /* GRDB.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = GRDB.xcodeproj; path = ../../../GRDB.xcodeproj; sourceTree = "<group>"; };
567C3E532520B75C0011F6E9 /* Player.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
567C3E542520B75C0011F6E9 /* Persistence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; };
567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerListViewModel.swift; sourceTree = "<group>"; };
567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormViewModel.swift; sourceTree = "<group>"; };
567C3E592520B75C0011F6E9 /* PlayerForm.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerForm.swift; sourceTree = "<group>"; };
567C3E5A2520B75C0011F6E9 /* PlayerList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = "<group>"; };
567C3E5B2520B75C0011F6E9 /* PlayerCreationSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationSheet.swift; sourceTree = "<group>"; };
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormView.swift; sourceTree = "<group>"; };
567C3E5A2520B75C0011F6E9 /* AppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = "<group>"; };
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationView.swift; sourceTree = "<group>"; };
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerEditionView.swift; sourceTree = "<group>"; };
567C3E652520B7880011F6E9 /* AppDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = "<group>"; };
567C3E762520BB650011F6E9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
567C3E782520BB650011F6E9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequest.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand All @@ -162,6 +166,7 @@
children = (
56026C9C25B8A7D000D1DF3F /* Info.plist */,
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */,
56717251261B334D00423B6F /* PlayerRequestTests.swift */,
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */,
);
path = GRDBCombineDemoTests;
Expand Down Expand Up @@ -212,10 +217,11 @@
567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */,
567C3E542520B75C0011F6E9 /* Persistence.swift */,
567C3E532520B75C0011F6E9 /* Player.swift */,
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */,
56717229261A185300423B6F /* Query.swift */,
567C3E1F2520B6DF0011F6E9 /* Preview Content */,
56185BC125B8047D00B9C30F /* Resources */,
56185BC225B8048B00B9C30F /* Support */,
567C3E552520B75C0011F6E9 /* ViewModels */,
567C3E582520B75C0011F6E9 /* Views */,
);
path = GRDBCombineDemo;
Expand Down Expand Up @@ -251,22 +257,14 @@
name = Frameworks;
sourceTree = "<group>";
};
567C3E552520B75C0011F6E9 /* ViewModels */ = {
isa = PBXGroup;
children = (
567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */,
567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
567C3E582520B75C0011F6E9 /* Views */ = {
isa = PBXGroup;
children = (
567C3E592520B75C0011F6E9 /* PlayerForm.swift */,
567C3E5A2520B75C0011F6E9 /* PlayerList.swift */,
567C3E5B2520B75C0011F6E9 /* PlayerCreationSheet.swift */,
567C3E5A2520B75C0011F6E9 /* AppView.swift */,
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */,
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */,
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */,
56717239261B23C800423B6F /* PlayerList.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -441,23 +439,25 @@
files = (
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */,
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */,
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
567C3E122520B6DE0011F6E9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5671722A261A185300423B6F /* Query.swift in Sources */,
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */,
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */,
567C3E612520B75D0011F6E9 /* PlayerForm.swift in Sources */,
567C3E632520B75D0011F6E9 /* PlayerCreationSheet.swift in Sources */,
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */,
5671723A261B23C800423B6F /* PlayerList.swift in Sources */,
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */,
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */,
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */,
567C3E622520B75D0011F6E9 /* PlayerList.swift in Sources */,
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */,
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */,
567C3E1A2520B6DE0011F6E9 /* GRDBCombineDemoApp.swift in Sources */,
567C3E5F2520B75C0011F6E9 /* PlayerListViewModel.swift in Sources */,
567C3E602520B75C0011F6E9 /* PlayerFormViewModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,25 @@ struct AppDatabase {
// MARK: - Database Access: Writes

extension AppDatabase {
/// A validation error that prevents some players from being saved into
/// the database.
enum ValidationError: LocalizedError {
case missingName

var errorDescription: String? {
switch self {
case .missingName:
return "Please provide a name"
}
}
}

/// Saves (inserts or updates) a player. When the method returns, the
/// player is present in the database, and its id is not nil.
func savePlayer(_ player: inout Player) throws {
if player.name.isEmpty {
throw ValidationError.missingName
}
try dbWriter.write { db in
try player.save(db)
}
Expand Down Expand Up @@ -125,19 +141,8 @@ extension AppDatabase {
// MARK: - Database Access: Reads

extension AppDatabase {
/// Returns a publisher that tracks changes in players ordered by name
func playersOrderedByNamePublisher() -> AnyPublisher<[Player], Error> {
ValueObservation
.tracking(Player.all().orderedByName().fetchAll)
.publisher(in: dbWriter, scheduling: .immediate)
.eraseToAnyPublisher()
}

/// Returns a publisher that tracks changes in players ordered by score
func playersOrderedByScorePublisher() -> AnyPublisher<[Player], Error> {
ValueObservation
.tracking(Player.all().orderedByScore().fetchAll)
.publisher(in: dbWriter, scheduling: .immediate)
.eraseToAnyPublisher()
/// Provides a read-only access to the database
var databaseReader: DatabaseReader {
dbWriter
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import GRDB
import SwiftUI

@main
struct GRDBCombineDemoApp: App {
let appDatabase = AppDatabase.shared

var body: some Scene {
WindowGroup {
PlayerList(viewModel: PlayerListViewModel(database: appDatabase))
AppView().environment(\.appDatabase, AppDatabase.shared)
}
}
}

// Let SwiftUI views access the database through the SwiftUI environment
private struct AppDatabaseKey: EnvironmentKey {
static let defaultValue: AppDatabase? = nil
}

extension EnvironmentValues {
var appDatabase: AppDatabase? {
get { self[AppDatabaseKey.self] }
set { self[AppDatabaseKey.self] = newValue }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import GRDB

/// A player request defines how to feed the player list
struct PlayerRequest {
enum Ordering {
case byScore
case byName
}

var ordering: Ordering
}

/// Make `PlayerRequest` able to be used with the `@Query` property wrapper.
extension PlayerRequest: Queryable {
static var defaultValue: [Player] { [] }

func fetchValue(_ db: Database) throws -> [Player] {
switch ordering {
case .byScore: return try Player.all().orderedByScore().fetchAll(db)
case .byName: return try Player.all().orderedByName().fetchAll(db)
}
}
}
109 changes: 109 additions & 0 deletions Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// Query.swift
//
// A property wrapper inspired from
// https://davedelong.com/blog/2021/04/03/core-data-and-swiftui/
//

import Combine
import GRDB
import SwiftUI

/// The protocol that feeds the `@Query` property wrapper.
protocol Queryable: Equatable {
/// The type of the fetched value
associatedtype Value

/// The default value, used whenever the database is not available
static var defaultValue: Value { get }

/// Fetches the database value
func fetchValue(_ db: Database) throws -> Value
}

/// The property wrapper that observes a database query
@propertyWrapper
struct Query<Query: Queryable>: DynamicProperty {
/// The database reader that makes it possible to observe the database
@Environment(\.appDatabase?.databaseReader) private var databaseReader: DatabaseReader?
@StateObject private var core = Core()
private var baseQuery: Query

/// The fetched value
var wrappedValue: Query.Value {
core.value ?? Query.defaultValue
}

/// A binding to the query, that lets your views modify it.
///
/// This is how the demo app changes the player ordering.
var projectedValue: Binding<Query> {
Binding(
get: { core.query ?? baseQuery },
set: { core.query = $0 })
}

init(_ query: Query) {
baseQuery = query
}

func update() {
guard let databaseReader = databaseReader else {
fatalError("Attempting to use @Query without any database in the environment")
}
// Feed core with necessary information, and make sure tracking has started
if core.query == nil { core.query = baseQuery }
core.startTrackingIfNecessary(in: databaseReader)
}

private class Core: ObservableObject {
private(set) var value: Query.Value?
var databaseReader: DatabaseReader?
var query: Query? {
willSet {
if query != newValue {
// Stop tracking, and tell SwiftUI about the update
objectWillChange.send()
cancellable = nil
}
}
}
private var cancellable: AnyCancellable?

init() { }

func startTrackingIfNecessary(in databaseReader: DatabaseReader) {
if databaseReader !== self.databaseReader {
// Database has changed. Stop tracking.
self.databaseReader = databaseReader
cancellable = nil
}

guard let query = query else {
// No query set
return
}

guard cancellable == nil else {
// Already tracking
return
}

cancellable = ValueObservation
.tracking(query.fetchValue)
.publisher(
in: databaseReader,
scheduling: .immediate)
.sink(
receiveCompletion: { _ in
// Ignore errors
},
receiveValue: { [weak self] value in
guard let self = self else { return }
// Tell SwiftUI about the new value
self.objectWillChange.send()
self.value = value
})
}
}
}
Loading