vapor / fluent

Vapor ORM (queries, models, and relations) for NoSQL and SQL databases
https://docs.vapor.codes/4.0/fluent/overview/
MIT License
1.3k stars 171 forks source link

Model Middleware Delete Action Never Gets Called by Deleting Pivot #731

Closed jens-andre closed 3 years ago

jens-andre commented 3 years ago

To Reproduce


// MARK: Models

final class Todo: Model, Content {
    static let schema = "todos"

    @ID(key: .id)
    var id: UUID?

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

    @Siblings(through: TodoTag.self, from: \.$todo, to: \.$tag)
    var tags: [Tag]

    init() { }

    init(id: UUID? = nil, title: String) {
        self.id = id
        self.title = title
    }
}

final class Tag: Model, Content {
    static let schema = "tags"

    @ID(key: .id)
    var id: UUID?

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

    init() { }

    init(id: UUID? = nil, name: String) {
        self.id = id
        self.name = name
    }
}

final class TodoTag: Model {
    static let schema = "todo+tag"

    @ID var id: UUID?

    @Parent(key: "todo_id")
    var todo: Todo

    @Parent(key: "tag_id")
    var tag: Tag

    init() {}

    init(id: UUID? = nil, todoID: UUID, tagID: UUID) {
        self.id = id
        self.$todo.id = todoID
        self.$tag.id = tagID
    }
}

struct TodoTagMiddleware: ModelMiddleware {
    func create(model: TodoTag, on db: Database, next: AnyModelResponder) -> EventLoopFuture<Void> {
        next.create(model, on: db).map { print("CREATE") }
    }

    func delete(model: TodoTag, force: Bool, on db: Database, next: AnyModelResponder) -> EventLoopFuture<Void> {
        next.delete(model, force: force, on: db).map { print("DELETE") }
    }
}

// MARK: Migrations

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

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

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

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

struct CreateTodoTag: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        database.schema(TodoTag.schema)
            .id()
            .field(
                "todo_id",
                .uuid,
                .required,
                .references(Todo.schema, .id, onDelete: .cascade)
            )
            .field(
                "tag_id",
                .uuid,
                .required,
                .references(Tag.schema, .id, onDelete: .cascade)
            )
            .unique(on: "todo_id", "tag_id")
            .create()
    }

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

// MARK: Controllers

struct TodoController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let todos = routes.grouped("todos")
        todos.get(use: index)
        todos.post(use: create)
        todos.group(":todoID") { todo in
            todo.delete(use: delete)
            todo.group("tags", ":tagID") { tags in
                tags.post(use: attachTag)
                tags.delete(use: detachTag)
            }
        }
    }

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

    func create(req: Request) throws -> EventLoopFuture<Todo> {
        let todo = try req.content.decode(Todo.self)
        return todo.save(on: req.db).map { todo }
    }

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

    func attachTag(req: Request) throws -> EventLoopFuture<HTTPStatus> {
        let todo = Todo.find(req.parameters.get("todoID"), on: req.db)
            .unwrap(or: Abort(.notFound))
        let tag = Tag.find(req.parameters.get("tagID"), on: req.db)
            .unwrap(or: Abort(.notFound))

        return todo.and(tag).flatMap { todo, tag in
            todo.$tags.attach(tag, on: req.db).transform(to: .ok)
        }
    }

    func detachTag(req: Request) throws -> EventLoopFuture<HTTPStatus> {
        let todo = Todo.find(req.parameters.get("todoID"), on: req.db)
            .unwrap(or: Abort(.notFound))
        let tag = Tag.find(req.parameters.get("tagID"), on: req.db)
            .unwrap(or: Abort(.notFound))

        return todo.and(tag).flatMap { todo, tag in
            todo.$tags.detach(tag, on: req.db).transform(to: .ok)
        }
    }
}

struct TagController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let tags = routes.grouped("tags")
        tags.get(use: index)
        tags.post(use: create)
        tags.group(":tagID") { todo in
            todo.delete(use: delete)
        }
    }

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

    func create(req: Request) throws -> EventLoopFuture<Tag> {
        let tag = try req.content.decode(Tag.self)
        return tag.save(on: req.db).map { tag }
    }

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

Expected behavior

CREATE gets printed on attaching a tag as expected, but on detaching the tag DELETE does not get printed.

Environment

0xTim commented 3 years ago

This is correct. The database itself is deleting the model because of the cascade delete - Fluent has no idea that it's been deleted to be able to run the middleware.

One option would be remove the cascade delete and delete the dependents in model middleware.

But capturing a cascade delete is not something Fluent will ever be able to do

jens-andre commented 3 years ago

Thank you very much for the quick response! How embarrassing, I should have thought of that myself. I’m very sorry for having wasted your time.

jens-andre commented 3 years ago

Sorry for bothering again… I removed the cascade delete but the delete action in TodoTagModelMiddleware never gets called. I created a repository, could you please take a look and test it (there is a paw file in the repo if it helps)? I also added a TodoModelMiddleware and a TagModelMiddleware which are behaving as expected.

0xTim commented 3 years ago

Did you do this from a fresh database that had been completely reset or did you just change the existing migration?

jens-andre commented 3 years ago

I changed the migration, deleted the database and did a fresh migration.

0xTim commented 3 years ago

You're not deleting the related models in your middleware?

0xTim commented 3 years ago

When are you expecting your pivot middleware to be called?

jens-andre commented 3 years ago

No, I’m sorry, should have explained it better… When detaching the Tag I’m expecting the middleware to be called. When attaching a Tag to a Todo CREATING and CREATED gets printed, but not for the delete action.