If you've read my other Vapor 4 posts, chance is that you use Vapor 4 yourself, and if that's the case you might have found yourself looking for sibling (many to many) relations between models.

Siblings in Fluent 4 is really easy to work with, once you get the hang of property wrappers. To get siblings working we need a pivot model, and if we take an example of users who owns books, we can have the following pivot:

final class UsersBooks: Model {
    static let schema = "usersbook"
    
    @ID(key: "id")
    var id: Int?
    
    @Parent(key: "user_id")
    var user: User
    
    @Parent(key: "book_id")
    var book: Book
    
    init() {}
    
    init(userID: Int,"bookID: Int) {
        self.$book.id = bookID
        self.$user.id = userID
    }
}

With each @Parent being the the models that needs to be related. This pivot allows us to bind multple books to multiple users, which I intend to use as books aren't exclusive to single users. Before we can bind this pivot to it's respective models, we also need to make a migration, I do it as follows:

extension UsersBooks {
    struct Migration: Fluent.Migration {
        let name = UsersBooks.schema
        
        func prepare(on database: Database) -> EventLoopFuture<Void> {
            database.schema(UsersBooks.schema)
                .field("id", .int, .identifier(auto: true))
                .field("user_id", .int, .required)
                .field("book_id", .int, .required)
                .create()
        }
        
        func revert(on database: Database) -> EventLoopFuture<Void> {
            database.schema(UsersBooks.schema).delete()
        }
    }
}

And remember to register it in configure.swift like you would any model. When this is all done, it's timne to add these to the models that needs to be glued together:

final class User: Model, Content {
    static let schema = "users"
    
    @ID(key: "id")
    var id: Int?

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

    @Field(key: "password_hash")
    var passwordHash: String
    
    @Field(key: "userRole")
    var role: UserRoles
    
    @Siblings(through: UsersBooks.self, from: \.$user, to: \.$book)
    var books: [Book]

    init() { }

    init(id: Int? = nil, name: String, email: String, passwordHash: String, role: UserRoles = .everyone) {
        self.id = id
        self.email = email
        self.passwordHash = passwordHash
        self.role = role
    }
}

...

final class Book: Model, Content {
	static let schema = "books"

	@ID(key: "id")
	var id: Int?

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

	@Siblings(through: UsersBooks.self, from: \.$book, to: \.$user)
	var owners: [User]

	init() {}

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

Note that from is the parent relation pointing to the current model, and to is the parent relation pointing to the related model. These two parent relations create a link from the current model to the target model via the pivot model. The siblings are not in the initilsation due to how they work in fluent, they're eager loaded which basically means they're automatically fetched by fluent on request.

Now that we have all this in place, we can finally find all books of a given user with the following:

func usersCollection(req: Request) throws -> EventLoopFuture<[Book]> {
    let userID: User.IDValue? = req.parameters.get("id")
    return User
        .find(userID, on: req.db)
        .unwrap(or: Abort(.notFound))
        .flatMap {
            try! $0.$books
                    .query(on: req.db)
                    .all()
        }
}

While the try! looks scary, there is no need to worry; if there is no books for a given user, it'll just return [], and we no there's a database connection because of earlier connection. When we need to attach a book to a owner, this method is what it takes:

func addBook(req: Request) throws -> EventLoopFuture<[Book]> {
    guard let userID: User.IDValue = req.parameters.get("id") else { throw Abort(.notFound) }
    guard let bookID: Book.IDValue = try? req.content.decode(Book.PurchaseContent.self).id else { throw Abort(.notFound) }
    _ = UsersBook(userID: userID, bookID: bookID).save(on: req.db)
    return try self.usersCollection(req: req)
}

It's worth considering making an extension to the model User with this function, so we don't need to pass parameters around. The _ = is to silence a warning, and I return usersCollection because I want to return something, alternatively it's worth consider return 200 success or something to the like.

Special Thanks

Tanner Nelson for provding help on the Discord.