Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Creating a Record Structure with Generic Types #1749

Closed
Artem-Viveritsa opened this issue Mar 24, 2025 · 3 comments
Closed

Creating a Record Structure with Generic Types #1749

Artem-Viveritsa opened this issue Mar 24, 2025 · 3 comments

Comments

@Artem-Viveritsa
Copy link

Let's say I have this structure:

struct Block: Syncable, Transferable {
            
    static var databaseTableName: String { "blocks" }
    
    var id:              UUID = UUID()

    ...
    ...
    ...
    
    var type:            BlockType // enum
    var data:            String = ""
    var parentId:        UUID?
    
    
    static var parent: BelongsToAssociation<Block, Block> { belongsTo(Block.self, key: "parent") }
    var parent: QueryInterfaceRequest<Block> { request(for: Block.parent) }
    
    static var children: HasManyAssociation<Block, Block> { hasMany(Block.self, key: "children") }
    var children: QueryInterfaceRequest<Block> { request(for: Block.children) }
    
    
    enum Columns {
        static var type: Column     { Column(CodingKeys.type) }
        static var data: JSONColumn { JSONColumn(CodingKeys.data) }
        static var parentId: Column { Column(CodingKeys.parentId) }
    }
    
    
    init(_ type: BlockType) { self.type = type }
    
    init(_ data: any BlockData) {
        self.type = Swift.type(of: data).type
        setProperties(data)
    }

    
    func getData<T: BlockData>(as type: T.Type) -> T? {
        try? JSONDecoder().decode(T.self, from: data.data(using: .utf8)!)
    }
    
    mutating func setData<T: BlockData>(_ value: T) {
        self.type = T.type
        if let data = try? value.toJSON() {
            self.data = data
        }
    }
    
    mutating func setParent(_ parent: Block?) { parentId = parent?.id }

}

Where the data field is currently represented by a json string. To get this data, you need to call the appropriate methods. But I would like it to have the correct type depending on its content:

struct Block<T: BlockData>: Syncable, Transferable {
            
    ...

    var data: T // Codable

And if the request for blocks of the same type works well:

struct BlockRequest<T: BlockData>: ValueObservationQueryable {
    static var defaultValue: Block<T> { Block<T>(.empty) }
    let blockId: UUID
    init(_ blockId: UUID) { self.blockId = blockId }
    func fetch(_ db: Database) throws -> Block<T> { try Block.find(db, id: blockId) }
}

Then what about those requests that should return blocks of different types?

struct BlocksRequest: ValueObservationQueryable {
    
    static var defaultValue: [Block<any BlockData>]  { [] }

Errors:
Type 'BlocksRequest' does not conform to protocol 'Queryable'
Type 'any BlockData' cannot conform to 'BlockData'
Generic parameter 'T' could not be inferred
...

Ideally, I want to make it so that the type of the T block is determined based on its type field, so that the maximum amount of work is done on the request side and ready-made blocks with the correct types come to the interface. Is this even possible?

@groue
Copy link
Owner

groue commented Mar 24, 2025

Hello @Artem-Viveritsa,

Then what about those requests that should return blocks of different types?

With Block<T: BlockData>, you don't have much choice.

Since the compiler won't have any BlockData conform to BlockData, you'll need to perform your own type erasing, with a concrete AnyBlockData type:

struct AnyBlockData: BlockData {
    init(_ base: any BlockData) { ... }
}

You'll probably need the type column when decoding, in something that looks like:

extension Block: FetchableRecord {
    init(row: Row) throws {
        let data: any BlockData
        let jsonDecoder = JSONDecoder()
        let jsonData: Data = row["data"]
        let type: BlockType = row["type"]
        switch type {
        case .type1:
            let data = try jsonDecoder.decode(BlockData1.self, from: jsonData)
        case .type2:
            let data = try jsonDecoder.decode(BlockData2.self, from: jsonData)
        // Other cases
        }

        if let data = data as? T {
            self.init(type: type, data: data)
        } else if T.self == AnyBlockData.self {
            self.init(type: type, data: AnyBlockData(data))
        } else {
            // The `type` column does not match our `T` type, we can't decode.
            throw /* some error */
        }
    }
}

@Artem-Viveritsa
Copy link
Author

Artem-Viveritsa commented Mar 24, 2025

Thank you very much @groue, it seems to be the right direction. I remade the whole project for this structure.

protocol BlockData: Codable, Hashable, Sendable, DatabaseValueConvertible  {
    static var type: BlockType { get }
}

struct AnyBlockData: BlockData {
    static let type: BlockType = .empty
    init(_ base: any BlockData) { }
}

But I can't figure out this point:

if let data = data as? T {
    print("From row init: \(data)")
    self.init(data)  // OK
} else if T.self == AnyBlockData.self {
    print(data) // Right data type...
    self.init(AnyBlockData(data) as! T) // ...but return as AnyBlockData
}
else {
    throw NSError()
}

// OK:
struct BlockRequest<T: BlockData>: ValueObservationQueryable {
    static var defaultValue: Block<T> { Block(EmptyBlockData() as! T) }

// Not OK:
struct BlocksRequest: ValueObservationQueryable {
    static var defaultValue: [Block<AnyBlockData>]  { [] }

It works only in such a way that the request returns an array (single record) of one type and this is expected.

But is it possible to make it so that there are blocks of different types inside? Or should it already be something like a dictionary or set? Preferably in one request to the database, because the type of blocks is unpredictable.

And such queries always cause an error because as! T

struct FullBlockRequest<T: BlockData>: ValueObservationQueryable {
    
    static var defaultValue: FullBlock<T> { FullBlock(block: Block(EmptyBlockData() as! T)) } // <---
    let blockId: UUID
    
    init(_ blockId: UUID) {
        self.blockId = blockId
    }
    
    func fetch(_ db: Database) throws -> FullBlock<T> {
        
//        try FullBlock.find(db, id: blockId) <--- Is it possible to use find instead of fetchOne?
        
        let request = Block
            .filter(id: blockId)
            .including(optional: Block.parent.deleteStatus(.notDeleted))
            .including(all: Block.children.deleteStatus(.notDeleted))
            .asRequest(of: FullBlock<AnyBlockData>.self)
        
        return try FullBlock.fetchOne(db, request)!
        
    }
}

@Artem-Viveritsa
Copy link
Author

In the end, I came to this decision, it is much easier in terms of support and allows me to change the type and structure of blocks on the fly:

struct AnyBlock: Syncable {

... 

    private func getData<T: BlockData>(as type: T.Type) -> T? {
        if let data = data.data(using: .utf8) {
            return try? JSONDecoder().decode(T.self, from: data)
        }
        return nil
    }
    
    @discardableResult
    private mutating func setData<T: BlockData>(_ value: T) -> Bool {
        if self.type == T.type, let newData = try? value.toJSON() {
            self.data = newData
            return true
        }
        return false
    }
    
    private mutating func setData<T: BlockData>(_ value: T?) -> Bool {
        if let newData = value {
            return setData(newData)
        }
        return false
    }
    
    mutating func setParent(_ parent: AnyBlock?) { parentId = parent?.id }
    
    @MainActor
    subscript<T: BlockData>(type: T.Type) -> T? {
        get { getData(as: type) }
        set(newValue) { if setData(newValue) { save() } }
    }
}

And then in SwiftUI:

if let data = block[TagData.self] { ... }

block[TagData.self]?.title = "GRDB❤️"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants