Vapor 4 and Fluent: Bug in postgres-kit/Sources/PostgresKit/PostgresDataDecoder.swift or my sources?

Hi Everybody,

during my Endeavour of using a JSONB-column using Fluent I encountered the following crash of my Server:

[ INFO ] POST /MapEditorBackend
[ INFO ] query create geo_jsons input=[[description: "Kampnagel Tabellen Test 4", id: 13C33818-DC92-4ECE-8A2D-919EC0FCBF2F]]
[ INFO ] query create geo_json_payloads input=[[geojson_id: 13C33818-DC92-4ECE-8A2D-919EC0FCBF2F, json: Optional(AnyCodable([:])), id: 2B91354A-6D91-4E0D-B994-7E8D620B24AA]]
[ INFO ] GET /MapEditorBackend
[ INFO ] query read geo_jsons
[ INFO ] DELETE /MapEditorBackend/13C33818-DC92-4ECE-8A2D-919EC0FCBF2F
[ INFO ] query read geo_jsons filters=[geo_jsons[id] = 13C33818-DC92-4ECE-8A2D-919EC0FCBF2F] limits=[count(1)]
[ INFO ] query read geo_json_payloads filters=[geo_json_payloads[geojson_id] ~~ [13C33818-DC92-4ECE-8A2D-919EC0FCBF2F]]
Fatal error: Unexpectedly found nil while unwrapping an Optional value: file /Users/lars/Documents/Projects/MapEditor/MapEditorBackend/.build/checkouts/postgres-kit/Sources/PostgresKit/PostgresDataDecoder.swift, line 143

being unsure whether this is a bug in PostgresDataDecoder or my code I want to show you what I did.

my model:

import Fluent
import Vapor

final class GeoJSON: Model, Content {
    static let schema = "geo_jsons"
    
    @ID(key: .id)
    var id: UUID?

    @Field(key: "description")
    var description: String

    @Children(for: \.$geoJSON)
    var geoJSONPayloads: [GeoJSONPayload]
    
    init() { }

    init(id: UUID? = nil, description: String) {
        self.id = id
        self.description = description
    }
}
import Fluent
import Vapor

final class GeoJSONPayload: Model, Content {
    static let schema = "geo_json_payloads"
    
    @ID(key: .id)
    var id: UUID?

    @Field(key: "json")
    var json: AnyCodable?
    
    @Parent(key: "geojson_id")
    var geoJSON: GeoJSON

    init() { }

    init(id: UUID? = nil, json: AnyCodable, geoJSONID: UUID) {
        self.id = id
        self.json = json
        self.$geoJSON.id = geoJSONID
    }
}

my migrations:

import Fluent

struct CreateGeoJSON: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("geo_jsons")
            .id()
            .field("description", .string, .required)
            .create()
    }

    func revert(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("geo_jsons").delete()
    }
}
import Fluent

struct CreateGeoJSONPayload: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("geo_json_payloads")
            .id()
            .field("json", .json)
            .field("geojson_id", .uuid, .references("geo_jsons", "id"))
            .create()
    }

    func revert(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("geo_json_payloads").delete()
    }
}

plus the code from AnyCodable/Sources/AnyCodable at master · Flight-School/AnyCodable · GitHub

my controller:

import Fluent
import Vapor

struct GeoJSONController {
    func index(req: Request) throws -> EventLoopFuture<[GeoJSON]> {
        return GeoJSON.query(on: req.db).all()
    }

    func create(req: Request) throws -> EventLoopFuture<GeoJSON> {
        let geoJSON = try req.content.decode(GeoJSON.self)
        return geoJSON.save(on: req.db).flatMap {
            let geoJSONPayload = GeoJSONPayload(json: AnyCodable(), geoJSONID: geoJSON.id!)
            
            return geoJSONPayload.save(on: req.db)
        }.map { geoJSON }
    }

    func get(req: Request) throws -> EventLoopFuture<String> {
        return GeoJSON.find(req.parameters.get("GeoJSONID"), on: req.db)
            .unwrap(or: Abort(.notFound))
            .flatMapThrowing { found in
                try Data.fromFile(found.id!.uuidString)
            }.map { data in
                String(decoding: data, as: UTF8.self)
            }.flatMapErrorThrowing { error in
                throw Abort(.notFound)
            }
    }
    
    func put(req: Request) throws -> EventLoopFuture<HTTPStatus> {
        let json = req.body.string ?? ""
        
        guard let jsonData = json.data(using: .utf8) else {
            throw Abort(.internalServerError)
        }
        
        guard let geoJSONID = req.parameters.get("GeoJSONID") , let uuid = UUID(geoJSONID) else {
            throw Abort(.notFound)
        }
        
        return GeoJSON.query(on: req.db).filter(\._$id == uuid)
            .with(\.$geoJSONPayloads).first()
            .unwrap(or: Abort(.notFound))
            .map { found in
                if let anyCodable = try? JSONDecoder().decode(AnyCodable.self, from: jsonData) {
                    found.geoJSONPayloads.first!.json = anyCodable
                } else {
                    found.geoJSONPayloads.first!.json = AnyCodable()
                }
            }.transform(to: .ok)
    }
    
    func delete(req: Request) throws -> EventLoopFuture<HTTPStatus> {
        guard let geoJSONID = req.parameters.get("GeoJSONID") , let uuid = UUID(geoJSONID) else {
            throw Abort(.notFound)
        }
        
        return GeoJSON.query(on: req.db).filter(\._$id == uuid)
            .with(\.$geoJSONPayloads).first()
            .unwrap(or: Abort(.notFound))
            .flatMap { found -> EventLoopFuture<Void> in
                found.geoJSONPayloads.first?.delete(on: req.db)
                return found.delete(on: req.db)
            }.transform(to: .ok)
    }
}

my routing:

import Fluent
import Vapor

func routes(_ app: Application) throws {
    app.get { req in
        return "It works!"
    }

    app.group("MapEditorBackend") { mapEditorBackend in
        let geoJSONController = GeoJSONController()
    
        mapEditorBackend.get(use: geoJSONController.index)
        mapEditorBackend.post(use: geoJSONController.create)
        
        mapEditorBackend.group(":GeoJSONID") { geoJSON in
            geoJSON.get(use: geoJSONController.get)
            geoJSON.put(use: geoJSONController.put)
            geoJSON.delete(use: geoJSONController.delete)
        }
    }
}

create (POST) works, the top level GET works, the GET using the GeoJSONID path param is still old (I was working with files in the file system before), PUT and DELETE fail alike (same error in Log like above)

Is this my fault or a bug in PostgresKit?

@tanner0101 @0xTim

Thanks in advance for your help!

kind regards,

Lars

With the help of Vapor I was able to fix that ugly DELETE case, now cascading delete works:

CreateGeoJSONPayload.swift:

struct CreateGeoJSONPayload: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("geo_json_payloads")
            .id()
            .field("json", .json)
            .field("geojson_id", .uuid, .references("geo_jsons", "id", onDelete: .cascade, onUpdate: .noAction))
            .create()
    }

    func revert(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("geo_json_payloads").delete()
    }
}

and:
GeoJSONController.swift:

    func delete(req: Request) throws -> EventLoopFuture<HTTPStatus> {
        return GeoJSON.find(req.parameters.get("GeoJSONID"), on: req.db)
            .unwrap(or: Abort(.notFound))
            .flatMap { found in found.delete(on: req.db) }
            .transform(to: .ok)
    }

adding onDelete: .cascade, onUpdate: .noAction to .references resulted in the following autogenerated DB-Script:

CREATE TABLE public.geo_json_payloads
(
    id uuid NOT NULL,
    json jsonb,
    geojson_id uuid,
    CONSTRAINT geo_json_payloads_pkey PRIMARY KEY (id),
    CONSTRAINT geo_json_payloads_geojson_id_fkey FOREIGN KEY (geojson_id)
        REFERENCES public.geo_jsons (id) MATCH SIMPLE
        ON UPDATE NO ACTION
        ON DELETE CASCADE
)

TABLESPACE pg_default;

ALTER TABLE public.geo_json_payloads
    OWNER to "MapEditor";

… but the PUT crash remains:

    func put(req: Request) throws -> EventLoopFuture<HTTPStatus> {
        let json = req.body.string ?? ""
        
        guard let jsonData = json.data(using: .utf8) else {
            throw Abort(.internalServerError)
        }
        
        guard let id = req.parameters.get("GeoJSONID", as: UUID.self) else {
            throw Abort(.notFound)
        }
        
        return GeoJSON.query(on: req.db).filter(\._$id == id)
            .with(\.$geoJSONPayloads).first()
            .unwrap(or: Abort(.notFound))
            .map { found in
                if let anyCodable = try? JSONDecoder().decode(AnyCodable.self, from: jsonData) {
                    found.geoJSONPayloads.first!.json = anyCodable
                } else {
                    found.geoJSONPayloads.first!.json = AnyCodable()
                }
            }.transform(to: .ok)
    }

leads to:

[ INFO ] PUT /MapEditorBackend/5C78468E-4F73-4ADF-A882-C82C06933FA6
[ INFO ] query read geo_jsons filters=[geo_jsons[id] = 5C78468E-4F73-4ADF-A882-C82C06933FA6] limits=[count(1)]
[ INFO ] query read geo_json_payloads filters=[geo_json_payloads[geojson_id] ~~ [5C78468E-4F73-4ADF-A882-C82C06933FA6]]
Fatal error: Unexpectedly found nil while unwrapping an Optional value: file /Users/lars/Documents/Projects/MapEditor/MapEditorBackend/.build/checkouts/postgres-kit/Sources/PostgresKit/PostgresDataDecoder.swift, line 143

any ideas?

See also: crasher in postgres-kit/Sources/PostgresKit/PostgresDataDecoder.swift, line 143 · Issue #175 · vapor/postgres-kit · GitHub