diff --git a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift index fba07e7..a5944dc 100644 --- a/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift +++ b/Examples/GRDBDemo/GRDB/GRDBQueryKey.swift @@ -3,15 +3,57 @@ import GRDB import Sharing import SwiftUI +extension SharedReaderKey { + /// 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 { + 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 { - public var defaultDatabase: DatabaseQueue { + /// The default database used by ``Sharing/SharedReaderKey/grdbQuery(_:animation:)``. + public var defaultDatabase: any DatabaseWriter { get { self[GRDBDatabaseKey.self] } set { self[GRDBDatabaseKey.self] = newValue } } private enum GRDBDatabaseKey: DependencyKey { - static var liveValue: DatabaseQueue { - reportIssue(""" + 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 \ the 'grdbQuery' key you can use the 'prepareDependencies' tool as soon as your app \ launches, such as in the entry point: @@ -20,17 +62,18 @@ extension DependencyValues { struct EntryPoint: App { init() { prepareDependencies { - $0.defaultDatabase = DatabaseQueue(…) + $0.defaultDatabase = try! DatabaseQueue(/* ... */) } } // ... } - """) + """ + ) return try! DatabaseQueue() } - static var testValue: DatabaseQueue { + static var testValue: any DatabaseWriter { try! DatabaseQueue() } } @@ -41,47 +84,36 @@ 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: Query, 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 databaseQueue: any DatabaseWriter + 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( in: databaseQueue, scheduling: .animation(animation) ) { error in - + reportIssue(error) } onChange: { newValue in receiveValue(newValue) } @@ -94,12 +126,31 @@ where Query.Value: Sendable { struct GRDBQueryID: Hashable { fileprivate let rawValue: AnyHashableSendable - init(rawValue: some GRDBQuery) { + init(rawValue: any GRDBQuery) { self.rawValue = AnyHashableSendable(rawValue) } } -struct AnimatedScheduler: ValueObservationScheduler { +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 } func schedule(_ action: @escaping @Sendable () -> Void) { @@ -116,7 +167,7 @@ struct AnimatedScheduler: ValueObservationScheduler { } extension ValueObservationScheduler where Self == AnimatedScheduler { - static func animation(_ animation: Animation?) -> Self { + fileprivate static func animation(_ animation: Animation?) -> Self { AnimatedScheduler(animation: animation) } } diff --git a/Examples/GRDBDemo/PlayersView.swift b/Examples/GRDBDemo/PlayersView.swift index eee92f8..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,10 +98,32 @@ 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 { - @Dependency(\.defaultDatabase) private var databaseQueue + @Dependency(\.defaultDatabase) private var database @Environment(\.dismiss) var dismiss @State var player = Player() @@ -111,7 +137,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 +153,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 d8a5abe..d1fe15e 100644 --- a/Examples/GRDBDemo/Schema.swift +++ b/Examples/GRDBDemo/Schema.swift @@ -45,37 +45,8 @@ extension DatabaseWriter { } } -enum PlayerOrder: String { case name, isInjured } - -extension SharedReaderKey where Self == GRDBQueryKey.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 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 +56,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)