GRDBQuery

repository·main·Indexed 43 days ago

https://github.com/groue/grdbquery

A library that helps SwiftUI applications access and observe a local SQLite database via GRDB and the SwiftUI environment. It provides property wrappers like @Query for direct database observation in views and @EnvironmentStateObject for building ObservableObject models in MVVM architectures. Supports iOS 14.0+, macOS 11+, tvOS 14.0+, and watchOS 7.0+ using Swift 6+ and Xcode 16+.

Tokens
16.6K
Snippets
48
Records
62
Agent score
55%

What's inside GRDBQuery

  1. Use the @Query property wrapper to observe database records

    main

    The @Query property wrapper allows SwiftUI views to directly read and observe database records. It automatically updates the view whenever the underlying database changes. You provide a request object (conforming to the necessary protocols) to the wrapper, and it manages the lifecycle of the database observation.

    /// Displays an always up-to-date list of database players.
    struct PlayerList: View {
        @Query(PlayersRequest()) var players: [Player]
        
        var body: some View {
            List(players) { player in Text(player.name) }
        }
    }
  2. Use FetchQueryable for single-value database fetches

    main

    Use the FetchQueryable protocol to create a Queryable type that emits a single value from a single database fetch. This is ideal for views that do not need to react to subsequent database changes once they have appeared on screen.

    To implement FetchQueryable, you must provide:

    1. A static var defaultValue: Value to provide an initial value.
    2. A func fetch(_ db: Database) throws -> Value to perform the actual database fetch.

    You can then use this type in a SwiftUI view by wrapping it with the @Query property wrapper.

    import GRDB
    import GRDBQuery
    
    struct PlayersRequest: FetchQueryable {
        static var defaultValue: [Player] { [] }
    
        func fetch(_ db: Database) throws -> [Player] {
            try Player.fetchAll(db)
        }
    }
    
    // Usage in SwiftUI
    struct PlayerPicker: View {
        @Binding var selection: Player
        @Query(PlayersRequest()) private var players: [Player]
        
        var body: some View {
            Picker("Select player", selection: $selection) {
                ForEach(players) { player in
                    Text(player.name).tag(player)
                }
            }
        }
    }
  3. MVVM Architecture Patterns in GRDBQuery

    main

    The GRDBQuery MVVM demo follows these architectural principles:

    1. Database as Single Source of Truth: All views feed from the database and communicate through it. Instead of passing complex state objects between views, views observe the database directly.
    2. Robustness against Database Changes: The application is designed to handle unexpected database changes (e.g., data being deleted while a view is still active) gracefully. For example, a sheet can be configured to dismiss itself automatically as soon as the specific record it is observing is removed from the database.
    3. Modular Data Access: The application accesses the database via a dedicated Swift Package (e.g., Players), separating the data access layer from the UI layer.
  4. Use @EnvironmentStateObject for MVVM and Dependency Injection

    main

    In SwiftUI, standard @StateObject does not support dependency injection via the SwiftUI environment because it requires immediate instantiation.

    @EnvironmentStateObject solves this by allowing you to instantiate an ObservableObject using values retrieved from the SwiftUI Environment. This enables a clean MVVM pattern where your ViewModels can receive dependencies (like databases or network clients) injected through the environment, making them highly testable and easy to configure for SwiftUI Previews.

    Key features:

    • Lifecycle Management: Like @StateObject, it instantiates the object before the initial body rendering and deallocates it when the view is no longer rendered.
    • Reactivity: SwiftUI updates views when published properties of the object change.
    • Binding Support: You can access bindings to properties using the $ operator.
    • Dependency Injection: It allows you to pass dependencies via EnvironmentKeys into the ViewModel's initializer.
    struct MyView: View {
        @EnvironmentStateObject var viewModel: MyViewModel
        
        init() {
            // The closure receives the current EnvironmentValues
            _viewModel = EnvironmentStateObject { env in
                MyViewModel(database: env.database, network: env.network)
            }
        }
    }
  5. Define a Queryable type to observe database values

    main

    A Queryable type is a structure that defines how to fetch data from the database and publishes those values to SwiftUI views. You can implement one of three specialized protocols derived from Queryable:

    1. ValueObservationQueryable: Observes database changes and publishes a sequence of values (e.g., a list of models).
    2. PresenceObservationQueryable: Specialized for observing the presence/absence of data.
    3. FetchQueryable: Performs a single fetch without continuous observation.

    To implement ValueObservationQueryable, you must provide a static var defaultValue and a fetch(_ db: Database) throws -> Value method.

    import GRDB
    import GRDBQuery
    
    /// Tracks the full list of players
    struct PlayersRequest: ValueObservationQueryable {
        static var defaultValue: [Player] { [] }
    
        func fetch(_ db: Database) throws -> [Player] {
            try Player.fetchAll(db)
        }
    }
  6. Implement the Queryable protocol

    main

    The Queryable protocol is used to feed the @Query property wrapper with a sequence of values over time. To implement it, you must provide:

    1. A defaultValue: An initial value displayed in the SwiftUI view until the publisher emits its first real value.
    2. A publisher(in:) method: This method takes a DatabaseContext and returns a Combine publisher that emits database values whenever they change.

    Queryable inherits from Equatable, allowing SwiftUI to efficiently manage view updates based on value changes.

    import Combine
    import GRDB
    import GRDBQuery
    
    struct PlayersRequest: Queryable {
        static var defaultValue: [Player] { [] }
    
        func publisher(in context: DatabaseContext) throws -> AnyPublisher<[Player], Error> {
            try ValueObservation
                .tracking { db in try Player.fetchAll(db) }
                .publisher(in: context.reader, scheduling: .immediate)
                .eraseToAnyPublisher()
        }
    }
  7. Choose between @Query and @EnvironmentStateObject

    main

    GRDBQuery provides two distinct patterns for accessing SQLite data in SwiftUI. You can use both in a single application depending on your architectural needs:

    1. @Query: Best for direct view-to-database observation. It is lightweight and excellent for rapid prototyping or simple views. It requires a type conforming to ValueObservationQueryable.
    2. @EnvironmentStateObject: Best for MVVM architectures. It allows you to move database observation into an ObservableObject (a ViewModel), keeping your views clean and focused on UI. It relies on the DatabaseContext provided via the SwiftUI environment.
  8. Use @EnvironmentStateObject to build ObservableObject models

    main

    The @EnvironmentStateObject property wrapper is used to build ObservableObject models from the SwiftUI environment. This is useful for more complex architectures where you want to inject a model that uses the databaseContext from the environment. You initialize the wrapper by providing a closure that receives the environment, allowing you to instantiate your model with the required database context.

    /// Displays an always up-to-date list of database players.
    struct PlayerList: View {
        @EnvironmentStateObject var model: PlayerListModel = []
        
        init() {
            _model = EnvironmentStateObject { env in
                PlayerListModel(databaseContext: env.databaseContext)
            }
        }
        
        var body: some View {
            List(model.players) { player in Text(player.name) }
        }
    }
  9. Use @EnvironmentStateObject to instantiate observable objects from the SwiftUI environment

    main

    The @EnvironmentStateObject property wrapper allows you to instantiate an ObservableObject using values currently present in the SwiftUI EnvironmentValues.

    It behaves similarly to @StateObject: it instantiates the object right before the initial body rendering and deallocates it when the view is no longer rendered. Changes to published properties will trigger view updates, and you can access bindings via the $ operator.

    Key Lifecycle Note: The lifetime of the object is tied to the view's identity. Once instantiated, changes to the environment values are ignored unless you force a new instantiation by changing the view's identity.

    import GRDBQuery
    import SwiftUI
    
    struct MyView: View {
        @EnvironmentStateObject private var model: MyModel
    
        init() {
            // The closure receives the current EnvironmentValues
            _model = EnvironmentStateObject { env in
                MyModel(database: env.database, network: env.network)
            }
        }
    
        var body: some View {
            // ...
        }
    }
  10. Use @EnvironmentStateObject to build MVVM models from the database context

    main

    The @EnvironmentStateObject property wrapper is used to build ObservableObject models that reside in the SwiftUI environment. This is the preferred method when following an MVVM architecture, as it allows you to decouple database observation logic from your SwiftUI views.

    In this pattern, you initialize the model within the view's init using the EnvironmentStateObject wrapper, passing in the databaseContext retrieved from the SwiftUI environment.

    Example

    /// Displays an always up-to-date list of database players.
    struct PlayerList: View {
        @EnvironmentStateObject var model: PlayerListModel = []
        
        init() {
            _model = EnvironmentStateObject { env in
                PlayerListModel(databaseContext: env.databaseContext)
            }
        }
        
        var body: some View {
            List(model.players) { player in Text(player.name) }
        }
    }
    
    class PlayerListModel: ObservableObject {
        @Published private(set) var players: [Player]
        private var cancellable: DatabaseCancellable?
    
        init(databaseContext: DatabaseContext) {
            let observation = ValueObservation.tracking { db in
                try Player.fetchAll(db)
            } 
            cancellable = observation.start(in: databaseContext.reader, scheduling: .immediate) { error in
                // Handle error
            } onChange: { players in
                self.players =  players
            }
        }
    }
    struct PlayerList: View {
        @EnvironmentStateObject var model: PlayerListModel = []
        
        init() {
            _model = EnvironmentStateObject { env in
                PlayerListModel(databaseContext: env.databaseContext)
            }
        }
        
        var body: some View {
            List(model.players) { player in Text(player.name) }
        }
    }
    
    class PlayerListModel: ObservableObject {
        @Published private(set) var players: [Player]
        private var cancellable: DatabaseCancellable?
    
        init(databaseContext: DatabaseContext) {
            let observation = ValueObservation.tracking { db in
                try Player.fetchAll(db)
            } 
            cancellable = observation.start(in: databaseContext.reader, scheduling: .immediate) { error in
                // Handle error
            } onChange: { players in
                self.players =  players
            }
        }
    }