Skip to content

RecordEncoder fails to encode optional AttributedString fields as JSON – "unkeyed encoding is not supported" #1502

@sipefree

Description

@sipefree

What did you do?

AttributedString structs are encodable using an @CodableConfiguration property wrapper provided with the subset of attributes that is allowed to be encoded into JSON.

I would expect GRDB to be able to handle encoding these values as JSON columns, but a subtlety in how CodableWithConfiguration types makes that impossible with the current implementation, but only when the field is optional.

Try this sample code:

import Foundation
import GRDB

let dbQueue = try DatabaseQueue()

var migrator = DatabaseMigrator()
migrator.registerMigration("createPosts") { db in
    try db.create(table: "post") { t in
        t.autoIncrementedPrimaryKey("id")
        t.column("title", .text).notNull()
        t.column("date", .date).notNull()
        t.column("text", .jsonText)
    }
}

try migrator.migrate(dbQueue)

struct Post: PersistableRecord, FetchableRecord, Hashable, Codable {
    var id: Int
    var title: String
    var date: Date

    @CodableConfiguration(from: \.foundation)
    var text: AttributedString? = nil
}

let post = Post(
    id: 0,
    title: "My first post.",
    date: Date(),
    text: try? AttributedString(markdown: "This is my **first** post!")
)

let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = .prettyPrinted
let postJSON = String(data: try jsonEncoder.encode(post), encoding: .utf8)!
print(postJSON)


try dbQueue.inTransaction { db in
    try post.insert(db) // <-- crash here
    return .commit
}

What did you expect to happen?

Expected the Post record to be inserted to the database.

What happened instead?

The program crashes by hitting one of these fatalErrors:

EncodableRecord+Encodable.swift:33

    func unkeyedContainer() -> UnkeyedEncodingContainer {
        fatalError("unkeyed encoding is not supported")
    }

or

EncodableRecord+Encodable.swift:48

    func singleValueContainer() -> SingleValueEncodingContainer {
        // [...]
        fatalError("single value encoding is not supported")
    }

This is because the AttributedString's encoding implementation will either open a singleValueContainer(), or an unkeyedContainer() depending on its internal representation, and this is getting called on the top level RecordEncoder rather than on an encoding container for a JSON value.

Note that this only happens when the AttributedString field is optional.

It appears to be related to how CodableWithConfiguration works when the field is optional:

From: https://github.com/apple/swift-corelibs-foundation/blob/778dde90ff7cf63f05634c3df6b0788c89249770/Sources/Foundation/AttributedString/AttributedStringCodable.swift#L116

public extension KeyedEncodingContainer {
    mutating func encode<T, C>(_ wrapper: CodableConfiguration<T?, C>, forKey key: Self.Key) throws {
        switch wrapper.wrappedValue {
        case .some(let val):
            try val.encode(to: self.superEncoder(forKey: key), configuration: C.encodingConfiguration)
            break
        default: break
        }
    }
}

This code is calling self.superEncoder(forKey: key), but the RecordEncoder.KeyedEncoder always returns recordEncoder for that method, when it should probably return something wrapping a JSON encoder. I have no idea why it's asking for a superEncoder, but this seems to be the way they've implemented it.

Environment

GRDB flavor(s): GRDB
GRDB version: 6.25.0
Installation method: SPM
Xcode version: Xcode 15
Swift version: 5.10
Platform(s) running GRDB: macOS
macOS version running Xcode: 14.3.1

Demo Project

GRDBAttrStringTestPkg.zip

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions