Skip to content
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
107 changes: 79 additions & 28 deletions Examples/GRDBDemo/GRDB/GRDBQueryKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>(
_ query: some GRDBQuery<Value>,
animation: Animation? = nil
) -> Self
where Self == GRDBQueryKey<Value> {
GRDBQueryKey(query: query, animation: animation)
}

/// A shared key that can query for data in a SQLite database.
static func query<Value: RangeReplaceableCollection>(
_ query: some GRDBQuery<Value>,
animation: Animation? = nil
) -> Self
where Self == GRDBQueryKey<Value>.Default {
Self[.query(query, animation: animation), default: Value()]
}

/// A shared key that can query for data in a SQLite database.
static func fetchAll<Value: FetchableRecord>(
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<Value: DatabaseValueConvertible>(
sql: String,
arguments: StatementArguments = StatementArguments(),
animation: Animation? = nil
) -> Self
where Self == GRDBQueryKey<Value> {
.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:
Expand All @@ -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()
}
}
Expand All @@ -41,47 +84,36 @@ protocol GRDBQuery<Value>: 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: Query, animation: Animation? = nil) -> Self
where Self == GRDBQueryKey<Query> {
GRDBQueryKey(query: query, animation: animation)
}
}

struct GRDBQueryKey<Query: GRDBQuery>: SharedReaderKey
where Query.Value: Sendable {
typealias Value = Query.Value

struct GRDBQueryKey<Value: Sendable>: SharedReaderKey {
let animation: Animation?
let databaseQueue: DatabaseQueue
let query: Query
let databaseQueue: any DatabaseWriter
let query: any GRDBQuery<Value>

typealias ID = GRDBQueryID

var id: ID { ID(rawValue: query) }

init(query: Query, animation: Animation? = nil) {
init(query: some GRDBQuery<Value>, 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)
}
Expand All @@ -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<Element: FetchableRecord>: 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<Value: DatabaseValueConvertible>: 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) {
Expand All @@ -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)
}
}
56 changes: 41 additions & 15 deletions Examples/GRDBDemo/PlayersView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,6 +43,8 @@ struct PlayersView: View {
}
}
.onDelete(perform: deleteItems)
} header: {
Text("^[\(uninjuredCount) player](inflect: true) are available")
}
}
}
Expand All @@ -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:")
}
Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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 {
Expand All @@ -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)
Expand Down
36 changes: 4 additions & 32 deletions Examples/GRDBDemo/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,37 +45,8 @@ extension DatabaseWriter {
}
}

enum PlayerOrder: String { case name, isInjured }

extension SharedReaderKey where Self == GRDBQueryKey<PlayersRequest>.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()
Expand All @@ -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)
Expand Down
Loading