GRDBQuery icon indicating copy to clipboard operation
GRDBQuery copied to clipboard

Simpler ways to write queries

Open Zomatree opened this issue 1 year ago • 2 comments

Hi,

The current way to write queries seem to be pretty verbose with requiring a separate struct and the default value being inside the query struct which can get a bit annoying when needing to write lots of these all over your codebase.

Is it possible for a simpler design to be added similar to this quick mockup?

// Before
struct UserCountQuery: ValueObservationQueryable {
    static var defaultValue: Int = 0
    
    func fetch(_ db: Database) throws -> Int {
        try User.fetchCount(db)
    }
}

@Query(UserCountQuery()) var userCount: Int

// After
@Query({ db in
	try User.fetchCount(db)
})
var userCount: Int = 0

Zomatree avatar Oct 13 '24 15:10 Zomatree

Hello @Zomatree,

This is not currently possible, given the static declaration of defaultValue in the Queryable protocol.

But I could make a rapid prototype that enables what you aim at:

struct MyView: View {
    @Query({ db in try User.fetchCount(db) }) // <-
    var userCount = 0

    var body: some View {
        Text("\(userCount) users")
    }
}

// MARK: - Support

extension Query where Request.Context == DatabaseContext {
    init<Value>(
        wrappedValue: Value,
        _ fetch: @escaping @Sendable (Database) throws -> Value
    )
    where Request == AnyObservableRequest<Value>
    {
        let queryable = AnyObservableRequest(
            defaultValue: wrappedValue,
            performFetch: fetch)
        
        self.init(queryable)
    }
}

struct AnyObservableRequest<Value: Sendable>: ValueObservationQueryable {
    var defaultValue: Value // <- not static
    var performFetch: @Sendable (Database) throws -> Value
    
    func fetch(_ db: Database) throws -> Value {
        try performFetch(db)
    }
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        // Here we tell GRDBQuery that there is
        // no parameter that could change
        // and that observation never needs to
        // be restarted.
        //
        // This is ok for a prototype, but not
        // in general: we break the substitutability
        // rule of the Equatable protocol.
        true
    }
}

In all honesty, I don't know if this convenience should ship in the library, because it does not scale well with growing application requirements.

Say you now have to count the specified subset of users, what would you write? The initial state of the code has no natural path for growth:

struct MyView: View {
    @Query(???)
    var userCount = 0

    init(registeredUsers: Bool) { ??? }
}

With the extra type, I find it easier to evolve the app, using the existing developer knowledge:

 struct UserCountQuery: ValueObservationQueryable {
     static var defaultValue: Int = 0
 
+    var registered: Bool
     
     func fetch(_ db: Database) throws -> Int {
-        try User.fetchCount(db)
+        try User
+            .filter(Column("registered") == registered)
+            .fetchCount(db)
     }
 }
 
 struct MyView: View {
-    @Query(UserCountQuery()) var userCount: Int
+    @Query<UserCountQuery> var userCount: Int
 
+    init(registeredUsers: Bool) {
+        _userCount = Query(UserCountQuery(registered: registeredUsers))
+    }
 }

I understand that this is debatable. But designing for app growth is one of my hobbies 😅 I very much dislike threshold effects and tipping points, and like that expectable small changes in app complexity can be implemented by extending the current code, instead of a complete rewrite. APIs that can't handle mundane changes without a complete rewrite are frustrating because they fail to build on the knowledge that has been acquired so far, and fail to show respect to the time that the developer has generously given to study.

Anyway, I was looking for a reason to make defaultValue an instance property instead of a static property, and you just gave me one. Even if GRDBQuery does not ship AnyObservableRequest, you would be able to add it to your app and get the convenience you're after.

groue avatar Oct 13 '24 16:10 groue

Another reason why the extra type is desirable is that a @Query initializer that is "too convenient" would drive people to write multiple @Query per view, which is almost always undesired, because this drops all guarantees of database integrity. The "good" way to is to fetch all the desired value from one Queryable type.

groue avatar Oct 14 '24 07:10 groue

Closing due to inactivity

groue avatar Nov 21 '24 07:11 groue