From 85b6ed30e71346ee101e746fc00bc47cde1b52ed Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 9 Dec 2024 14:10:51 -0800 Subject: [PATCH 1/7] Make `GRDBQueryKey` generic over value Making keys generic over their value makes it easier to define extensions for type-safe keys and defaults. It introduces an existential, but it shouldn't be a big performance hit. --- Examples/GRDBDemo/GRDB/GRDBQueryKey.swift | 19 ++++++++----------- Examples/GRDBDemo/Schema.swift | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index fba07e7..8f38a97 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -43,38 +43,35 @@ protocol GRDBQuery: Hashable, Sendable { extension SharedReaderKey { /// A shared key that can query for data in a SQLite database. - static func grdbQuery(_ query: Query, animation: Animation? = nil) -> Self - where Self == GRDBQueryKey { + static func grdbQuery(_ query: some GRDBQuery, animation: Animation? = nil) -> Self + where Self == GRDBQueryKey { GRDBQueryKey(query: query, animation: animation) } } -struct GRDBQueryKey: SharedReaderKey -where Query.Value: Sendable { - typealias Value = Query.Value - +struct GRDBQueryKey: SharedReaderKey { let animation: Animation? let databaseQueue: DatabaseQueue - let query: Query + let query: any GRDBQuery typealias ID = GRDBQueryID var id: ID { ID(rawValue: query) } - init(query: Query, animation: Animation? = nil) { + init(query: some GRDBQuery, animation: Animation? = nil) { @Dependency(\.defaultDatabase) var databaseQueue self.animation = animation self.databaseQueue = databaseQueue self.query = query } - func load(initialValue: Query.Value?) -> Query.Value? { + func load(initialValue: Value?) -> Value? { (try? databaseQueue.read(query.fetch)) ?? initialValue } func subscribe( - initialValue: Query.Value?, - didSet receiveValue: @escaping @Sendable (Query.Value?) -> Void + initialValue: Value?, + didSet receiveValue: @escaping @Sendable (Value?) -> Void ) -> SharedSubscription { let observation = ValueObservation.tracking(query.fetch) let cancellable = observation.start( diff --git a/Examples/GRDBDemo/Schema.swift b/Examples/GRDBDemo/Schema.swift index d8a5abe..0573516 100644 --- a/Examples/GRDBDemo/Schema.swift +++ b/Examples/GRDBDemo/Schema.swift @@ -47,7 +47,7 @@ extension DatabaseWriter { enum PlayerOrder: String { case name, isInjured } -extension SharedReaderKey where Self == GRDBQueryKey.Default { +extension SharedReaderKey where Self == GRDBQueryKey<[Player]>.Default { static func players(order: PlayerOrder = .name) -> Self { Self[ .grdbQuery(PlayersRequest(order: order), animation: .default), From c085af4d3087d6c8cdaa097aa18fa374c86162ba Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 9 Dec 2024 14:41:25 -0800 Subject: [PATCH 2/7] wip --- Examples/GRDBDemo/GRDB/GRDBQueryKey.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index 8f38a97..964af8c 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -11,7 +11,8 @@ extension DependencyValues { private enum GRDBDatabaseKey: DependencyKey { static var liveValue: DatabaseQueue { - reportIssue(""" + reportIssue( + """ A blank, in-memory database is being used for the app. To set the database that is used by \ the 'grdbQuery' key you can use the 'prepareDependencies' tool as soon as your app \ launches, such as in the entry point: @@ -20,13 +21,14 @@ extension DependencyValues { struct EntryPoint: App { init() { prepareDependencies { - $0.defaultDatabase = DatabaseQueue(…) + $0.defaultDatabase = try! DatabaseQueue(/* ... */) } } // ... } - """) + """ + ) return try! DatabaseQueue() } @@ -96,7 +98,7 @@ struct GRDBQueryID: Hashable { } } -struct AnimatedScheduler: ValueObservationScheduler { +private struct AnimatedScheduler: ValueObservationScheduler { let animation: Animation? func immediateInitialValue() -> Bool { true } func schedule(_ action: @escaping @Sendable () -> Void) { From eef81192e3572224ece6b72d91bdad5bbef7a005 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 9 Dec 2024 14:42:05 -0800 Subject: [PATCH 3/7] wip --- Examples/GRDBDemo/GRDB/GRDBQueryKey.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index 964af8c..342267b 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -80,7 +80,7 @@ struct GRDBQueryKey: SharedReaderKey { in: databaseQueue, scheduling: .animation(animation) ) { error in - + reportIssue(error) } onChange: { newValue in receiveValue(newValue) } From 6dc1fa8d73a3a016d4f26edf5996161b95471ddc Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 9 Dec 2024 14:43:57 -0800 Subject: [PATCH 4/7] wip --- Examples/GRDBDemo/GRDB/GRDBQueryKey.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index 342267b..02196e4 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -3,6 +3,14 @@ import GRDB import Sharing import SwiftUI +extension SharedReaderKey { + /// A shared key that can query for data in a SQLite database. + static func grdbQuery(_ query: some GRDBQuery, animation: Animation? = nil) -> Self + where Self == GRDBQueryKey { + GRDBQueryKey(query: query, animation: animation) + } +} + extension DependencyValues { public var defaultDatabase: DatabaseQueue { get { self[GRDBDatabaseKey.self] } @@ -43,14 +51,6 @@ protocol GRDBQuery: Hashable, Sendable { func fetch(_ db: Database) throws -> Value } -extension SharedReaderKey { - /// A shared key that can query for data in a SQLite database. - static func grdbQuery(_ query: some GRDBQuery, animation: Animation? = nil) -> Self - where Self == GRDBQueryKey { - GRDBQueryKey(query: query, animation: animation) - } -} - struct GRDBQueryKey: SharedReaderKey { let animation: Animation? let databaseQueue: DatabaseQueue From 12ac8ebab61b29b15ed68d1979ffff013817ad2e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 9 Dec 2024 14:53:04 -0800 Subject: [PATCH 5/7] wip --- Examples/GRDBDemo/GRDB/GRDBQueryKey.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index 02196e4..852342a 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -5,13 +5,17 @@ import SwiftUI extension SharedReaderKey { /// A shared key that can query for data in a SQLite database. - static func grdbQuery(_ query: some GRDBQuery, animation: Animation? = nil) -> Self + static func grdbQuery( + _ query: some GRDBQuery, + animation: Animation? = nil + ) -> Self where Self == GRDBQueryKey { GRDBQueryKey(query: query, animation: animation) } } extension DependencyValues { + /// The default database used by ``Sharing/SharedReaderKey/grdbQuery(_:animation:)``. public var defaultDatabase: DatabaseQueue { get { self[GRDBDatabaseKey.self] } set { self[GRDBDatabaseKey.self] = newValue } @@ -115,7 +119,7 @@ private struct AnimatedScheduler: ValueObservationScheduler { } extension ValueObservationScheduler where Self == AnimatedScheduler { - static func animation(_ animation: Animation?) -> Self { + fileprivate static func animation(_ animation: Animation?) -> Self { AnimatedScheduler(animation: animation) } } From 5ad17efa197203b1752bc2151df73903f0d66b81 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 9 Dec 2024 16:47:22 -0800 Subject: [PATCH 6/7] wip --- Examples/GRDBDemo/GRDB/GRDBQueryKey.swift | 8 ++++---- Examples/GRDBDemo/PlayersView.swift | 8 ++++---- Examples/GRDBDemo/Schema.swift | 7 ++++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index 852342a..47572cf 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -16,13 +16,13 @@ extension SharedReaderKey { extension DependencyValues { /// The default database used by ``Sharing/SharedReaderKey/grdbQuery(_:animation:)``. - public var defaultDatabase: DatabaseQueue { + public var defaultDatabase: any DatabaseWriter { get { self[GRDBDatabaseKey.self] } set { self[GRDBDatabaseKey.self] = newValue } } private enum GRDBDatabaseKey: DependencyKey { - static var liveValue: DatabaseQueue { + static var liveValue: any DatabaseWriter { reportIssue( """ A blank, in-memory database is being used for the app. To set the database that is used by \ @@ -44,7 +44,7 @@ extension DependencyValues { return try! DatabaseQueue() } - static var testValue: DatabaseQueue { + static var testValue: any DatabaseWriter { try! DatabaseQueue() } } @@ -57,7 +57,7 @@ protocol GRDBQuery: Hashable, Sendable { struct GRDBQueryKey: SharedReaderKey { let animation: Animation? - let databaseQueue: DatabaseQueue + let databaseQueue: any DatabaseWriter let query: any GRDBQuery typealias ID = GRDBQueryID diff --git a/Examples/GRDBDemo/PlayersView.swift b/Examples/GRDBDemo/PlayersView.swift index eee92f8..5f1fe61 100644 --- a/Examples/GRDBDemo/PlayersView.swift +++ b/Examples/GRDBDemo/PlayersView.swift @@ -97,7 +97,7 @@ struct PlayersView: View { } struct AddPlayerView: View { - @Dependency(\.defaultDatabase) private var databaseQueue + @Dependency(\.defaultDatabase) private var database @Environment(\.dismiss) var dismiss @State var player = Player() @@ -111,7 +111,7 @@ struct AddPlayerView: View { .toolbar { Button("Save") { do { - try databaseQueue.write { db in + try database.write { db in _ = try player.inserted(db) } } catch { @@ -127,8 +127,8 @@ struct AddPlayerView: View { #Preview( traits: .dependency(\.defaultDatabase, .appDatabase) ) { - @Dependency(\.defaultDatabase) var databaseQueue - let _ = try! databaseQueue.write { db in + @Dependency(\.defaultDatabase) var database + let _ = try! database.write { db in for index in 0...9 { _ = try Player(name: "Blob \(index)", isInjured: index.isMultiple(of: 3)) .inserted(db) diff --git a/Examples/GRDBDemo/Schema.swift b/Examples/GRDBDemo/Schema.swift index 0573516..3969736 100644 --- a/Examples/GRDBDemo/Schema.swift +++ b/Examples/GRDBDemo/Schema.swift @@ -74,8 +74,8 @@ struct PlayersRequest: GRDBQuery { } } -extension DatabaseQueue { - static var appDatabase: DatabaseQueue { +extension DatabaseWriter where Self == DatabaseQueue { + static var appDatabase: Self { let path = URL.documentsDirectory.appending(component: "db.sqlite").path() print("open", path) var configuration = Configuration() @@ -85,7 +85,8 @@ extension DatabaseQueue { } } let databaseQueue: DatabaseQueue - if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil { + @Dependency(\.context) var context + if context == .live { databaseQueue = try! DatabaseQueue(path: path, configuration: configuration) } else { databaseQueue = try! DatabaseQueue(configuration: configuration) From a5f51d5c894c22f70bd67a37733af63c05f126ff Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 10 Dec 2024 12:24:23 -0800 Subject: [PATCH 7/7] wip --- Examples/GRDBDemo/GRDB/GRDBQueryKey.swift | 52 ++++++++++++++++++++++- Examples/GRDBDemo/PlayersView.swift | 48 ++++++++++++++++----- Examples/GRDBDemo/Schema.swift | 29 ------------- 3 files changed, 87 insertions(+), 42 deletions(-) diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index 47572cf..a5944dc 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -5,13 +5,42 @@ import SwiftUI extension SharedReaderKey { /// A shared key that can query for data in a SQLite database. - static func grdbQuery( + static func query( _ query: some GRDBQuery, animation: Animation? = nil ) -> Self where Self == GRDBQueryKey { GRDBQueryKey(query: query, animation: animation) } + + /// A shared key that can query for data in a SQLite database. + static func query( + _ query: some GRDBQuery, + animation: Animation? = nil + ) -> Self + where Self == GRDBQueryKey.Default { + Self[.query(query, animation: animation), default: Value()] + } + + /// A shared key that can query for data in a SQLite database. + static func fetchAll( + sql: String, + arguments: StatementArguments = StatementArguments(), + animation: Animation? = nil + ) -> Self + where Self == GRDBQueryKey<[Value]>.Default { + Self[.query(FetchAll(sql: sql, arguments: arguments), animation: animation), default: []] + } + + /// A shared key that can query for data in a SQLite database. + static func fetchOne( + sql: String, + arguments: StatementArguments = StatementArguments(), + animation: Animation? = nil + ) -> Self + where Self == GRDBQueryKey { + .query(FetchOne(sql: sql, arguments: arguments), animation: animation) + } } extension DependencyValues { @@ -97,11 +126,30 @@ struct GRDBQueryKey: SharedReaderKey { struct GRDBQueryID: Hashable { fileprivate let rawValue: AnyHashableSendable - init(rawValue: some GRDBQuery) { + init(rawValue: any GRDBQuery) { self.rawValue = AnyHashableSendable(rawValue) } } +private struct FetchAll: GRDBQuery { + var sql: String + var arguments: StatementArguments = StatementArguments() + func fetch(_ db: Database) throws -> [Element] { + try Element.fetchAll(db, sql: sql, arguments: arguments) + } +} + +private struct FetchOne: GRDBQuery { + var sql: String + var arguments: StatementArguments = StatementArguments() + func fetch(_ db: Database) throws -> Value { + guard let value = try Value.fetchOne(db, sql: sql, arguments: arguments) + else { throw NotFound() } + return value + } + struct NotFound: Error {} +} + private struct AnimatedScheduler: ValueObservationScheduler { let animation: Animation? func immediateInitialValue() -> Bool { true } diff --git a/Examples/GRDBDemo/PlayersView.swift b/Examples/GRDBDemo/PlayersView.swift index 5f1fe61..cc6a0e3 100644 --- a/Examples/GRDBDemo/PlayersView.swift +++ b/Examples/GRDBDemo/PlayersView.swift @@ -7,22 +7,24 @@ import SwiftUI @MainActor private let readMe: LocalizedStringKey = """ This app demonstrates a simple way to persist data with GRDB. It introduces a new \ - `SharedReaderKey` conformance, `grdbQuery`, which queries a database for populating state. When \ - the database is updated the state will automatically be refreshed. + `SharedReaderKey` conformance, `query`, which queries a database for populating state. When the \ + database is updated the state will automatically be refreshed. - A list of players is powered by `grdbQuery`. The demo also shows how to perform a dynamic \ - query on the players in the form of sorting the list by name or their injury status. + A list of players is powered by `query`. The demo also shows how to perform a dynamic query on \ + the players in the form of sorting the list by name or their injury status. """ struct PlayersView: View { + @State private var aboutIsPresented = false + @State private var addPlayerIsPresented = false @Dependency(\.defaultDatabase) private var database + @Shared(.appStorage("order")) private var order: Players.Order = .name @SharedReader private var players: [Player] - @Shared(.appStorage("order")) private var order = PlayerOrder.name - @State private var addPlayerIsPresented = false - @State private var aboutIsPresented = false + @SharedReader(.fetchOne(sql: #"SELECT count(*) FROM "players" WHERE "isInjured" = 0"#)) + private var uninjuredCount = 0 init() { - _players = SharedReader(.players(order: _order.wrappedValue)) + _players = SharedReader(.query(Players(order: _order.wrappedValue))) } var body: some View { @@ -41,6 +43,8 @@ struct PlayersView: View { } } .onDelete(perform: deleteItems) + } header: { + Text("^[\(uninjuredCount) player](inflect: true) are available") } } } @@ -49,8 +53,8 @@ struct PlayersView: View { ToolbarItem { Picker("Sort", selection: Binding($order)) { Section { - Text("Name").tag(PlayerOrder.name) - Text("Is injured?").tag(PlayerOrder.isInjured) + Text("Name").tag(Players.Order.name) + Text("Is injured?").tag(Players.Order.isInjured) } header: { Text("Sort by:") } @@ -71,7 +75,7 @@ struct PlayersView: View { } } .onChange(of: order) { - $players = SharedReader(.players(order: order)) + $players = SharedReader(.query(Players(order: order))) } .sheet(isPresented: $addPlayerIsPresented) { AddPlayerView() @@ -94,6 +98,28 @@ struct PlayersView: View { reportIssue(error) } } + + struct Players: GRDBQuery { + enum Order: String { case name, isInjured } + let order: Order + init(order: Order = .name) { + self.order = order + } + func fetch(_ db: Database) throws -> [Player] { + let ordering: any SQLOrderingTerm = + switch order { + case .name: + Column("name") + case .isInjured: + Column("isInjured").desc + } + return + try Player + .all() + .order(ordering) + .fetchAll(db) + } + } } struct AddPlayerView: View { diff --git a/Examples/GRDBDemo/Schema.swift b/Examples/GRDBDemo/Schema.swift index 3969736..d1fe15e 100644 --- a/Examples/GRDBDemo/Schema.swift +++ b/Examples/GRDBDemo/Schema.swift @@ -45,35 +45,6 @@ extension DatabaseWriter { } } -enum PlayerOrder: String { case name, isInjured } - -extension SharedReaderKey where Self == GRDBQueryKey<[Player]>.Default { - static func players(order: PlayerOrder = .name) -> Self { - Self[ - .grdbQuery(PlayersRequest(order: order), animation: .default), - default: [] - ] - } -} - -struct PlayersRequest: GRDBQuery { - let order: PlayerOrder - func fetch(_ db: Database) throws -> [Player] { - let ordering: any SQLOrderingTerm = - switch order { - case .name: - Column("name") - case .isInjured: - Column("isInjured").desc - } - return - try Player - .all() - .order(ordering) - .fetchAll(db) - } -} - extension DatabaseWriter where Self == DatabaseQueue { static var appDatabase: Self { let path = URL.documentsDirectory.appending(component: "db.sqlite").path()