From 6958a10a08d65b95c290183995dd855b56423ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sun, 4 Apr 2021 15:24:17 +0200 Subject: [PATCH] Refactor the SwiftUI demo application --- .../GRDBCombineDemo.xcodeproj/project.pbxproj | 56 ++++----- .../GRDBCombineDemo/AppDatabase.swift | 33 ++--- .../GRDBCombineDemo/GRDBCombineDemoApp.swift | 17 ++- .../GRDBCombineDemo/PlayerRequest.swift | 23 ++++ .../GRDBCombineDemo/Query.swift | 109 ++++++++++++++++ .../ViewModels/PlayerFormViewModel.swift | 67 ---------- .../ViewModels/PlayerListViewModel.swift | 118 ------------------ .../GRDBCombineDemo/Views/AppView.swift | 92 ++++++++++++++ ...onSheet.swift => PlayerCreationView.swift} | 28 ++--- .../Views/PlayerEditionView.swift | 29 +++-- .../GRDBCombineDemo/Views/PlayerForm.swift | 39 ------ .../Views/PlayerFormView.swift | 29 +++++ .../GRDBCombineDemo/Views/PlayerList.swift | 117 +++-------------- .../AppDatabaseTests.swift | 108 ---------------- .../PlayerRequestTests.swift | 43 +++++++ .../DemoApps/GRDBCombineDemo/README.md | 17 +-- .../xcschemes/GRDBDemoWatchOS.xcscheme | 25 +--- Documentation/DemoApps/GRDBDemoiOS/README.md | 2 +- Documentation/DemoApps/README.md | 5 +- GRDB.xcodeproj/project.pbxproj | 2 +- 20 files changed, 422 insertions(+), 537 deletions(-) create mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/PlayerRequest.swift create mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift delete mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift delete mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerListViewModel.swift create mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/AppView.swift rename Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/{PlayerCreationSheet.swift => PlayerCreationView.swift} (65%) delete mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerForm.swift create mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerFormView.swift create mode 100644 Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/PlayerRequestTests.swift diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj index 853e801c34..cca13534bb 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* 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 */; }; @@ -16,15 +19,14 @@ 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 */ @@ -119,6 +121,9 @@ 56026C9C25B8A7D000D1DF3F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTests.swift; sourceTree = ""; }; 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabaseTests.swift; sourceTree = ""; }; + 56717229261A185300423B6F /* Query.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Query.swift; sourceTree = ""; }; + 56717239261B23C800423B6F /* PlayerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = ""; }; + 56717251261B334D00423B6F /* PlayerRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequestTests.swift; sourceTree = ""; }; 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 = ""; }; 567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -127,15 +132,14 @@ 567C3E292520B7000011F6E9 /* GRDB.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = GRDB.xcodeproj; path = ../../../GRDB.xcodeproj; sourceTree = ""; }; 567C3E532520B75C0011F6E9 /* Player.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; 567C3E542520B75C0011F6E9 /* Persistence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; - 567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerListViewModel.swift; sourceTree = ""; }; - 567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormViewModel.swift; sourceTree = ""; }; - 567C3E592520B75C0011F6E9 /* PlayerForm.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerForm.swift; sourceTree = ""; }; - 567C3E5A2520B75C0011F6E9 /* PlayerList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = ""; }; - 567C3E5B2520B75C0011F6E9 /* PlayerCreationSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationSheet.swift; sourceTree = ""; }; + 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormView.swift; sourceTree = ""; }; + 567C3E5A2520B75C0011F6E9 /* AppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; + 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationView.swift; sourceTree = ""; }; 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerEditionView.swift; sourceTree = ""; }; 567C3E652520B7880011F6E9 /* AppDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = ""; }; 567C3E762520BB650011F6E9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; 567C3E782520BB650011F6E9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequest.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -162,6 +166,7 @@ children = ( 56026C9C25B8A7D000D1DF3F /* Info.plist */, 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */, + 56717251261B334D00423B6F /* PlayerRequestTests.swift */, 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */, ); path = GRDBCombineDemoTests; @@ -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; @@ -251,22 +257,14 @@ name = Frameworks; sourceTree = ""; }; - 567C3E552520B75C0011F6E9 /* ViewModels */ = { - isa = PBXGroup; - children = ( - 567C3E562520B75C0011F6E9 /* PlayerListViewModel.swift */, - 567C3E572520B75C0011F6E9 /* PlayerFormViewModel.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; 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 = ""; @@ -441,6 +439,7 @@ files = ( 56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */, 56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */, + 56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -448,16 +447,17 @@ 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; }; diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift index e1a5acb2b7..018ae34e4f 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/AppDatabase.swift @@ -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) } @@ -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 } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift index 224200cecc..aef733adec 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/GRDBCombineDemoApp.swift @@ -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 } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/PlayerRequest.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/PlayerRequest.swift new file mode 100644 index 0000000000..7950554804 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/PlayerRequest.swift @@ -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) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift new file mode 100644 index 0000000000..083227e006 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Query.swift @@ -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: 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 { + 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 + }) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift deleted file mode 100644 index 169a73cb99..0000000000 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Combine -import Foundation - -/// The view model that validates and saves an edited player into the database. -/// -/// It feeds `PlayerForm`, `PlayerCreationSheet` and `PlayerEditionView`. -final class PlayerFormViewModel: ObservableObject { - /// A validation error that prevents the player from being saved into - /// the database. - enum ValidationError: LocalizedError { - case missingName - - var errorDescription: String? { - switch self { - case .missingName: - return "Please give a name to this player." - } - } - } - - @Published var name: String = "" - @Published var score: String = "" - - private let database: AppDatabase - private var player: Player - - init(database: AppDatabase, player: Player) { - self.database = database - self.player = player - updateViewFromPlayer() - } - - // MARK: - Manage the Player Form - - /// Validates and saves the player into the database. - func savePlayer() throws { - if name.isEmpty { - throw ValidationError.missingName - } - player.name = name - player.score = Int(score) ?? 0 - try database.savePlayer(&player) - } - - /// Resets form values to the original player values. - func reset() { - updateViewFromPlayer() - } - - /// Edits a new player - func editNewPlayer() { - player = .new() - updateViewFromPlayer() - } - - // MARK: - Private - - private func updateViewFromPlayer() { - self.name = player.name - if player.score == 0 && player.id == nil { - // Avoid displaying "0" for a new player: it does not look good. - self.score = "" - } else { - self.score = "\(player.score)" - } - } -} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerListViewModel.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerListViewModel.swift deleted file mode 100644 index 22dd15c5e5..0000000000 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/ViewModels/PlayerListViewModel.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Combine -import Foundation - -/// The view model that feeds `PlayerList`, and performs list modifications -/// in the database. -final class PlayerListViewModel: ObservableObject { - enum Ordering { - case byScore - case byName - } - - struct PlayerList { - var players: [Player] - var animatedChanges: Bool - } - - /// The list ordering - @Published var ordering: Ordering = .byScore - - /// The players in the list - @Published var playerList = PlayerList(players: [], animatedChanges: false) - - /// The view model that edits a new player - let newPlayerViewModel: PlayerFormViewModel - - private let database: AppDatabase - private var playersCancellable: AnyCancellable? - - init(database: AppDatabase) { - self.database = database - newPlayerViewModel = PlayerFormViewModel(database: database, player: .new()) - playersCancellable = playersPublisher(in: database) - .scan(nil) { (previousList: PlayerList?, players: [Player]) in - if previousList == nil { - // Do not animate first view update - return PlayerList(players: players, animatedChanges: false) - } else { - return PlayerList(players: players, animatedChanges: true) - } - } - .compactMap { $0 } - .sink { [weak self] playerList in - self?.playerList = playerList - } - } - - // MARK: - Players List Management - - /// Deletes all players - func deleteAllPlayers() { - // Eventual error presentation is left as an exercise for the reader. - try! database.deleteAllPlayers() - } - - func deletePlayers(atOffsets offsets: IndexSet) { - // Eventual error presentation is left as an exercise for the reader. - let playerIDs = offsets.compactMap { playerList.players[$0].id } - try! database.deletePlayers(ids: playerIDs) - } - - /// Refreshes the list of players - func refreshPlayers() { - // Eventual error presentation is left as an exercise for the reader. - try! database.refreshPlayers() - } - - /// Spawns many concurrent database updates, for demo purpose - func stressTest() { - for _ in 0..<50 { - DispatchQueue.global().async { - self.refreshPlayers() - } - } - } - - // MARK: - Change Player Ordering - - /// Toggles between the available orderings - func toggleOrdering() { - switch ordering { - case .byName: - ordering = .byScore - case .byScore: - ordering = .byName - } - } - - // MARK: - Player Edition - - /// Returns a view model suitable for editing a player. - func formViewModel(for player: Player) -> PlayerFormViewModel { - PlayerFormViewModel(database: database, player: player) - } - - // MARK: - Private - - /// Returns a publisher of the players in the list - private func playersPublisher(in database: AppDatabase) -> AnyPublisher<[Player], Never> { - // Players depend on the current ordering - $ordering.map { ordering -> AnyPublisher<[Player], Error> in - switch ordering { - case .byScore: - return database.playersOrderedByScorePublisher() - case .byName: - return database.playersOrderedByNamePublisher() - } - } - .map { playersPublisher in - // Turn database errors into an empty players list. - // Eventual error presentation is left as an exercise for the reader. - playersPublisher.catch { error in - Just<[Player]>([]) - } - } - .switchToLatest() - .eraseToAnyPublisher() - } -} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/AppView.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/AppView.swift new file mode 100644 index 0000000000..2e4d911340 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/AppView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +/// The main application view +struct AppView: View { + /// Database access + @Environment(\.appDatabase) private var appDatabase + + /// The `players` property is kept up-to-date with the list of players. + @Query(PlayerRequest(ordering: .byScore)) private var players: [Player] + + /// Tracks the presentation of the player creation sheet. + @State private var newPlayerIsPresented = false + + var body: some View { + NavigationView { + PlayerList(players: players) + .navigationBarTitle(Text("\(players.count) Players")) + .navigationBarItems( + leading: HStack { + EditButton() + newPlayerButton + }, + trailing: ToggleOrderingButton(ordering: $players.ordering)) + .toolbar { toolbarContent } + } + } + + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .bottomBar) { + Button( + action: { try? appDatabase?.deleteAllPlayers() }, + label: { Image(systemName: "trash").imageScale(.large) }) + Spacer() + Button( + action: { try? appDatabase?.refreshPlayers() }, + label: { Image(systemName: "arrow.clockwise").imageScale(.large) }) + } + } + + /// The button that presents the player creation sheet. + private var newPlayerButton: some View { + Button( + action: { newPlayerIsPresented = true }, + label: { Image(systemName: "plus") }) + .sheet( + isPresented: $newPlayerIsPresented, + content: { + PlayerCreationView(dismissAction: { + newPlayerIsPresented = false + }) + }) + } +} + +private struct ToggleOrderingButton: View { + @Binding var ordering: PlayerRequest.Ordering + + var body: some View { + switch ordering { + case .byName: + Button( + action: { ordering = .byScore }, + label: { + HStack { + Text("Name") + Image(systemName: "arrowtriangle.up.fill") + } + }) + case .byScore: + Button( + action: { ordering = .byName }, + label: { + HStack { + Text("Score") + Image(systemName: "arrowtriangle.down.fill") + } + }) + } + } +} + +struct AppView_Previews: PreviewProvider { + static var previews: some View { + Group { + // Preview a database of random players + AppView().environment(\.appDatabase, .random()) + + // Preview an empty database + AppView().environment(\.appDatabase, .empty()) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationSheet.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationView.swift similarity index 65% rename from Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationSheet.swift rename to Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationView.swift index 1adc4c698d..122e0811d6 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationSheet.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerCreationView.swift @@ -1,36 +1,37 @@ import SwiftUI -/// The Player creation sheet -struct PlayerCreationSheet: View { - /// Manages the player form - let viewModel: PlayerFormViewModel - +/// The view that creates a new player. +struct PlayerCreationView: View { /// Executed when user cancels or saves the new user. let dismissAction: () -> Void + @Environment(\.appDatabase) private var appDatabase + @State private var name = "" + @State private var score = "" @State private var errorAlertIsPresented = false @State private var errorAlertTitle = "" var body: some View { NavigationView { - PlayerForm(viewModel: viewModel) + PlayerFormView(name: $name, score: $score) .alert( isPresented: $errorAlertIsPresented, content: { Alert(title: Text(errorAlertTitle)) }) .navigationBarTitle("New Player") .navigationBarItems( leading: Button( - action: self.dismissAction, + action: dismissAction, label: { Text("Cancel") }), trailing: Button( - action: self.save, + action: save, label: { Text("Save") })) } } private func save() { do { - try viewModel.savePlayer() + var player = Player(id: nil, name: name, score: Int(score) ?? 0) + try appDatabase?.savePlayer(&player) dismissAction() } catch { errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred" @@ -41,12 +42,7 @@ struct PlayerCreationSheet: View { struct PlayerCreationSheet_Previews: PreviewProvider { static var previews: some View { - let viewModel = PlayerFormViewModel( - database: .empty(), - player: .new()) - - return PlayerCreationSheet( - viewModel: viewModel, - dismissAction: { }) + PlayerCreationView(dismissAction: { }) + .environment(\.appDatabase, .empty()) } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift index 85652a52c4..eff839e2f9 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerEditionView.swift @@ -1,28 +1,27 @@ import SwiftUI -/// The Player edition view, designed to be the destination of -/// a NavigationLink. +/// The view that edits an existing player. struct PlayerEditionView: View { - /// Manages the player form - let viewModel: PlayerFormViewModel + @Environment(\.appDatabase) private var appDatabase + @State var player: Player var body: some View { - PlayerForm(viewModel: viewModel) - .onDisappear(perform: { - // Ignore validation errors - try? self.viewModel.savePlayer() - }) + PlayerFormView( + name: $player.name, + score: Binding( + get: { "\(player.score)" }, + set: { player.score = Int($0) ?? 0 })) + .onDisappear { + // save and ignore error + try? appDatabase?.savePlayer(&player) + } } } struct PlayerEditionView_Previews: PreviewProvider { static var previews: some View { - let viewModel = PlayerFormViewModel( - database: .empty(), - player: .newRandom()) - - return NavigationView { - PlayerEditionView(viewModel: viewModel) + NavigationView { + PlayerEditionView(player: Player.newRandom()) .navigationBarTitle("Player Edition") } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerForm.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerForm.swift deleted file mode 100644 index b780d195b3..0000000000 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerForm.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI - -/// The Player editing form, embedded in both -/// `PlayerCreationSheet` and `PlayerEditionView`. -struct PlayerForm: View { - /// Manages the player form - @ObservedObject var viewModel: PlayerFormViewModel - - var body: some View { - List { - TextField("Name", text: $viewModel.name) - TextField("Score", text: $viewModel.score) - .keyboardType(.numberPad) - } - .listStyle(GroupedListStyle()) - // Make sure the form is reset, in case a previous edition ended - // with a validation error. - // - // The bug we want to prevent is the following: - // - // 1. Launch the app - // 2. Tap a player - // 3. Erase the name so that validation fails - // 4. Hit the back button - // 5. Tap the same player - // 6. Bug: the form displays an empty name. - .onAppear(perform: viewModel.reset) - } -} - -struct PlayerFormView_Previews: PreviewProvider { - static var previews: some View { - let viewModel = PlayerFormViewModel( - database: .empty(), - player: .newRandom()) - - return PlayerForm(viewModel: viewModel) - } -} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerFormView.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerFormView.swift new file mode 100644 index 0000000000..aa35e2a70d --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerFormView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// The Player editing form, embedded in both +/// `PlayerCreationView` and `PlayerEditionView`. +struct PlayerFormView: View { + @Binding var name: String + @Binding var score: String + + var body: some View { + List { + TextField("Name", text: $name) + TextField("Score", text: $score).keyboardType(.numberPad) + } + .listStyle(InsetGroupedListStyle()) + } +} + +struct PlayerFormView_Previews: PreviewProvider { + static var previews: some View { + Group { + PlayerFormView( + name: .constant(""), + score: .constant("")) + PlayerFormView( + name: .constant(Player.randomName()), + score: .constant("\(Player.randomScore())")) + } + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift index 654cb3354d..76dbe3783f 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemo/Views/PlayerList.swift @@ -1,117 +1,35 @@ import SwiftUI -/// The list of players struct PlayerList: View { - /// Manages the list of players - @ObservedObject var viewModel: PlayerListViewModel + /// Database access + @Environment(\.appDatabase) private var appDatabase - /// Controls the presentation of the player creation sheet. - @State private var newPlayerIsPresented = false + /// The players in the list + var players: [Player] var body: some View { - NavigationView { - VStack { - playerList - toolbar - } - .navigationBarTitle(Text("\(viewModel.playerList.players.count) Players")) - .navigationBarItems( - leading: HStack { - EditButton() - newPlayerButton - }, - trailing: toggleOrderingButton) - } - } - - private var playerList: some View { List { - ForEach(viewModel.playerList.players) { player in - NavigationLink(destination: self.editionView(for: player)) { - PlayerRow(player: player) - .animation(nil) + ForEach(players) { player in + NavigationLink(destination: editionView(for: player)) { + PlayerRow(player: player).animation(nil) } } .onDelete(perform: { offsets in - self.viewModel.deletePlayers(atOffsets: offsets) + let playerIds = offsets.compactMap { players[$0].id } + try? appDatabase?.deletePlayers(ids: playerIds) }) } + .animation(.default) .listStyle(PlainListStyle()) - .animation(viewModel.playerList.animatedChanges ? .default : nil) - } - - private var toolbar: some View { - HStack { - Button( - action: viewModel.deleteAllPlayers, - label: { Image(systemName: "trash").imageScale(.large) }) - Spacer() - Button( - action: viewModel.refreshPlayers, - label: { Image(systemName: "arrow.clockwise").imageScale(.large) }) - Spacer() - Button( - action: viewModel.stressTest, - label: { Image(systemName: "tornado").imageScale(.large) }) - } - - .padding() - } - - /// The button that toggles between name/score ordering. - private var toggleOrderingButton: some View { - switch viewModel.ordering { - case .byName: - return Button(action: viewModel.toggleOrdering, label: { - HStack { - Text("Name") - Image(systemName: "arrowtriangle.up.fill") - .imageScale(.small) - } - }) - case .byScore: - return Button(action: viewModel.toggleOrdering, label: { - HStack { - Text("Score") - Image(systemName: "arrowtriangle.down.fill") - .imageScale(.small) - } - }) - } } /// The view that edits a player in the list. private func editionView(for player: Player) -> some View { - PlayerEditionView( - viewModel: viewModel.formViewModel(for: player)) - .navigationBarTitle(player.name) - } - - /// The button that presents the player creation sheet. - private var newPlayerButton: some View { - Button( - action: { - // Make sure we do not edit a previously created player. - self.viewModel.newPlayerViewModel.editNewPlayer() - self.newPlayerIsPresented = true - }, - label: { Image(systemName: "plus").imageScale(.large) }) - .sheet( - isPresented: $newPlayerIsPresented, - content: { self.newPlayerCreationSheet }) - } - - /// The player creation sheet. - private var newPlayerCreationSheet: some View { - PlayerCreationSheet( - viewModel: self.viewModel.newPlayerViewModel, - dismissAction: { - self.newPlayerIsPresented = false - }) + PlayerEditionView(player: player).navigationBarTitle(player.name) } } -struct PlayerRow: View { +private struct PlayerRow: View { var player: Player var body: some View { @@ -123,9 +41,14 @@ struct PlayerRow: View { } } -struct PlayerListView_Previews: PreviewProvider { +struct PlayerList_Previews: PreviewProvider { static var previews: some View { - let viewModel = PlayerListViewModel(database: .random()) - return PlayerList(viewModel: viewModel) + NavigationView { + PlayerList(players: [ + Player(id: 1, name: "Arthur", score: 100), + Player(id: 2, name: "Barbara", score: 1000), + ]) + .navigationTitle("Preview") + } } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift index 20e319249f..78d21fb12a 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/AppDatabaseTests.swift @@ -143,112 +143,4 @@ class AppDatabaseTests: XCTestCase { let players = try dbQueue.read(Player.fetchAll) XCTAssertEqual(players, [player]) } - - func test_playersOrderedByNamePublisher_publishes_well_ordered_players() throws { - // Given a players database that contains two players - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - var player1 = Player(id: nil, name: "Arthur", score: 100) - var player2 = Player(id: nil, name: "Barbara", score: 1000) - try dbQueue.write { db in - try player1.insert(db) - try player2.insert(db) - } - - // When we observe players and wait for the first value - let exp = expectation(description: "Players") - var players: [Player]? - let cancellable = appDatabase.playersOrderedByNamePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - exp.fulfill() - } - withExtendedLifetime(cancellable) { - waitForExpectations(timeout: 1, handler: nil) - } - - // Then the players are the two players ordered by name - XCTAssertEqual(players, [player1, player2]) - } - - func test_playersOrderedByNamePublisher_publishes_right_on_subscripion() throws { - // Our SwiftUI views have no "waiting" state, and must be fed with - // players without any delay in order to avoid any rendering glitch. - // This test makes sure `playersOrderedByNamePublisher` publishes right - // on subscription. - - // Given a players database - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - - // When we observe players - var players: [Player]? - _ = appDatabase.playersOrderedByNamePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - } - - // Then the players are published right on subscription - XCTAssertNotNil(players) - } - - func test_playersOrderedByScorePublisher_publishes_well_ordered_players() throws { - // Given a players database that contains two players - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - var player1 = Player(id: nil, name: "Arthur", score: 100) - var player2 = Player(id: nil, name: "Barbara", score: 1000) - try dbQueue.write { db in - try player1.insert(db) - try player2.insert(db) - } - - // When we observe players and wait for the first value - let exp = expectation(description: "Players") - var players: [Player]? - let cancellable = appDatabase.playersOrderedByScorePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - exp.fulfill() - } - withExtendedLifetime(cancellable) { - waitForExpectations(timeout: 1, handler: nil) - } - - // Then the players are the two players ordered by score descending - XCTAssertEqual(players, [player2, player1]) - } - - func test_playersOrderedByScorePublisher_publishes_right_on_subscripion() throws { - // Our SwiftUI views have no "waiting" state, and must be fed with - // players without any delay in order to avoid any rendering glitch. - // This test makes sure `playersOrderedByScorePublisher` publishes right - // on subscription. - - // Given a players database - let dbQueue = DatabaseQueue() - let appDatabase = try AppDatabase(dbQueue) - - // When we observe players - var players: [Player]? - _ = appDatabase.playersOrderedByScorePublisher().sink { completion in - if case let .failure(error) = completion { - XCTFail("Unexpected error \(error)") - } - } receiveValue: { - players = $0 - } - - // Then the players are published right on subscription - XCTAssertNotNil(players) - } } diff --git a/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/PlayerRequestTests.swift b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/PlayerRequestTests.swift new file mode 100644 index 0000000000..e0e7f23f65 --- /dev/null +++ b/Documentation/DemoApps/GRDBCombineDemo/GRDBCombineDemoTests/PlayerRequestTests.swift @@ -0,0 +1,43 @@ +import XCTest +import GRDB +@testable import GRDBCombineDemo + +class PlayerRequestTests: XCTestCase { + func test_PlayerRequest_byName_fetches_well_ordered_players() throws { + // Given a players database that contains two players + let dbQueue = DatabaseQueue() + _ = try AppDatabase(dbQueue) + var player1 = Player(id: nil, name: "Arthur", score: 100) + var player2 = Player(id: nil, name: "Barbara", score: 1000) + try dbQueue.write { db in + try player1.insert(db) + try player2.insert(db) + } + + // When we fetch players ordered by name + let playerRequest = PlayerRequest(ordering: .byName) + let players = try dbQueue.read(playerRequest.fetchValue) + + // Then the players are the two players ordered by name + XCTAssertEqual(players, [player1, player2]) + } + + func test_PlayerRequest_byScore_fetches_well_ordered_players() throws { + // Given a players database that contains two players + let dbQueue = DatabaseQueue() + _ = try AppDatabase(dbQueue) + var player1 = Player(id: nil, name: "Arthur", score: 100) + var player2 = Player(id: nil, name: "Barbara", score: 1000) + try dbQueue.write { db in + try player1.insert(db) + try player2.insert(db) + } + + // When we fetch players ordered by score + let playerRequest = PlayerRequest(ordering: .byScore) + let players = try dbQueue.read(playerRequest.fetchValue) + + // Then the players are the two players ordered by score descending + XCTAssertEqual(players, [player2, player1]) + } +} diff --git a/Documentation/DemoApps/GRDBCombineDemo/README.md b/Documentation/DemoApps/GRDBCombineDemo/README.md index 620a245525..29d09fcf90 100644 --- a/Documentation/DemoApps/GRDBCombineDemo/README.md +++ b/Documentation/DemoApps/GRDBCombineDemo/README.md @@ -3,7 +3,7 @@ Combine + SwiftUI Demo Application -**This demo application is a Combine + SwiftUI application, based on the MVVM design pattern.** For a demo application that uses UIKit, see [GRDBDemoiOS](../GRDBDemoiOS/README.md). +**This demo application is a Combine + SwiftUI application.** For a demo application that uses UIKit, see [GRDBDemoiOS](../GRDBDemoiOS/README.md). > :point_up: **Note**: This demo app is not a project template. Do not copy it as a starting point for your application. Instead, create a new project, choose a GRDB [installation method](../../../README.md#installation), and use the demo as an inspiration. @@ -19,7 +19,7 @@ The topics covered in this demo are: - [GRDBCombineDemoApp.swift](GRDBCombineDemo/GRDBCombineDemoApp.swift) - `GRDBCombineDemoApp` feeds the app views with a database. + `GRDBCombineDemoApp` feeds the app views with a database, through the SwiftUI environment. - [AppDatabase.swift](GRDBCombineDemo/AppDatabase.swift) @@ -31,18 +31,19 @@ The topics covered in this demo are: - [Player.swift](GRDBCombineDemo/Player.swift) - `Player` is a [Record](../../../README.md#records) type, able to read and write in the database. It conforms to the standard Codable protocol in order to gain all advantages of [Codable Records](../../../README.md#codable-records). It defines the database requests used by the application. + `Player` is a [Record](../../../README.md#records) type, able to read and write in the database. It conforms to the standard Codable protocol in order to gain all advantages of [Codable Records](../../../README.md#codable-records). -- [PlayerList.swift](GRDBCombineDemo/Views/PlayerList.swift) and [PlayerListViewModel.swift](GRDBCombineDemo/ViewModels/PlayerListViewModel.swift) +- [PlayerRequest.swift](GRDBCombineDemo/PlayerRequest.swift), [Query.swift](GRDBCombineDemo/Query.swift), [AppView.swift](GRDBCombineDemo/Views/AppView.swift) - `PlayerList` is the SwiftUI view that displays the list of players, fed by `PlayerListViewModel`. - -- [PlayerForm.swift](GRDBCombineDemo/Views/PlayerForm.swift), [PlayerEditionView.swift](GRDBCombineDemo/Views/PlayerEditionView.swift), [PlayerCreationSheet.swift](GRDBCombineDemo/Views/PlayerCreationSheet.swift) and [PlayerFormViewModel.swift](GRDBCombineDemo/ViewModels/PlayerFormViewModel.swift). + `PlayerRequest` defines the player requests used by the app (sorted by score, or by name). + + `PlayerRequest` feeds the `@Query` property wrapper. `@Query`, inspired by [this article](https://davedelong.com/blog/2021/04/03/core-data-and-swiftui/), allows SwiftUI views to display up-to-date database content thanks to GRDB's [ValueObservation](../../../README.md#valueobservation). - `PlayerForm` is the SwiftUI view that displays a Player editing form. It is embedded in `PlayerEditionView` and `PlayerCreationSheet`, two SwiftUI views that edit or create a player. All those views are fed by `PlayerFormViewModel`. + `AppView` is the SwiftUI view that uses `@Query` in order to feed its player list. - [GRDBCombineDemoTests](GRDBCombineDemoTests) - Test the database schema - Test the `Player` record and its requests + - Test the `PlayerRequest` methods that feed the list of players. - Test the `AppDatabase` methods that let the app access the database. diff --git a/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme b/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme index 4fbad19f2b..237e9f7b3e 100644 --- a/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme +++ b/Documentation/DemoApps/GRDBDemoiOS/GRDBDemoiOS.xcodeproj/xcshareddata/xcschemes/GRDBDemoWatchOS.xcscheme @@ -78,10 +78,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - + - + - + - - - - - + diff --git a/Documentation/DemoApps/GRDBDemoiOS/README.md b/Documentation/DemoApps/GRDBDemoiOS/README.md index 1227e514a1..9c8d6b1ae6 100644 --- a/Documentation/DemoApps/GRDBDemoiOS/README.md +++ b/Documentation/DemoApps/GRDBDemoiOS/README.md @@ -3,7 +3,7 @@ UIKit Demo Application -**This demo application is a storyboard-based UIKit application, based on the MVC design pattern.** For a demo application that uses Combine and SwiftUI, see [GRDBCombineDemo](../GRDBCombineDemo/README.md). +**This demo application is a storyboard-based UIKit application.** For a demo application that uses Combine and SwiftUI, see [GRDBCombineDemo](../GRDBCombineDemo/README.md). > :point_up: **Note**: This demo app is not a project template. Do not copy it as a starting point for your application. Instead, create a new project, choose a GRDB [installation method](../../../README.md#installation), and use the demo as an inspiration. diff --git a/Documentation/DemoApps/README.md b/Documentation/DemoApps/README.md index 6be27437e9..6316ac0394 100644 --- a/Documentation/DemoApps/README.md +++ b/Documentation/DemoApps/README.md @@ -1,6 +1,5 @@ Demo Applications ================= -- [UIKit Demo Application](GRDBDemoiOS/README.md): a storyboard-based UIKit application, based on the MVC design pattern. -- [Combine + SwiftUI Demo Application](GRDBCombineDemo/README.md): a Combine + SwiftUI application, based on the MVVM design pattern. - +- [UIKit Demo Application](GRDBDemoiOS/README.md): a storyboard-based UIKit application. +- [Combine + SwiftUI Demo Application](GRDBCombineDemo/README.md): a Combine + SwiftUI application. diff --git a/GRDB.xcodeproj/project.pbxproj b/GRDB.xcodeproj/project.pbxproj index 7154e86a34..68a630095b 100755 --- a/GRDB.xcodeproj/project.pbxproj +++ b/GRDB.xcodeproj/project.pbxproj @@ -2445,8 +2445,8 @@ DC37742D19C8CC90004FCF85 /* GRDB */ = { isa = PBXGroup; children = ( - 56A2386F1B9C75030082EB20 /* Core */, 56A2FA3524424D2A00E97D23 /* Export.swift */, + 56A2386F1B9C75030082EB20 /* Core */, 56D7E449221595FE0052464B /* Fixit */, 5698AC291D9E5A480056AF8C /* FTS */, 56A238911B9C750B0082EB20 /* Migration */,