Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Vapor 3, Fluent 3 and Many-to-Many relations not working as expected

Tags:

swift

vapor

Just started with Vapor 3 along with a MySQL database and I am having hard time figuring out the Relations part.

I have created 2 models so far: Movie and Actor. A Movie can have many Actors and an Actor can have many Movies.

Movie Model:

import Vapor
import FluentMySQL

final class Movie: Codable {

    var id: Int?
    var name: String
    var synopsis: String
    var dateReleased: Date
    var totalGrossed: Float

    init(id: Int? = nil, name: String, synopsis: String, dateReleased: Date, totalGrossed: Float) {
        self.id = id
        self.name = name
        self.synopsis = synopsis
        self.dateReleased = dateReleased
        self.totalGrossed = totalGrossed
    }

}

extension Movie {
    var actors: Siblings<Movie, Actor, MovieActor> {
        return siblings()
    }
}

extension Movie: Content {}
extension Movie: Parameter {}
extension Movie: MySQLModel {}

extension Movie: MySQLMigration {
    static func prepare(on conn: MySQLConnection) -> Future<Void> {
        return MySQLDatabase.create(self, on: conn) { builder in
            builder.field(for: \.id, isIdentifier: true)
            builder.field(for: \.name)
            builder.field(for: \.synopsis)
            builder.field(for: \.dateReleased, type: .date)
            builder.field(for: \.totalGrossed, type: .float)
        }
    }
}

Actor Model:

import Vapor
import FluentMySQL

final class Actor: Codable {

    var id: Int?
    var firstName: String
    var lastName: String
    var fullName: String {
        return firstName + " " + lastName
    }
    var dateOfBirth: Date
    var story: String

    init(id: Int? = nil, firstName: String, lastName: String, dateOfBirth: Date, story: String) {
        self.id = id
        self.firstName = firstName
        self.lastName = lastName
        self.dateOfBirth = dateOfBirth
        self.story = story
    }

}

extension Actor {
    var actors: Siblings<Actor, Movie, MovieActor> {
        return siblings()
    }
}

extension Actor: Content {}
extension Actor: Parameter {}
extension Actor: MySQLModel {}

extension Actor: MySQLMigration {
    static func prepare(on conn: MySQLConnection) -> Future<Void> {
        return MySQLDatabase.create(self, on: conn) { builder in
            builder.field(for: \.id, isIdentifier: true)
            builder.field(for: \.firstName)
            builder.field(for: \.lastName)
            builder.field(for: \.dateOfBirth, type: .date)
            builder.field(for: \.story, type: .text)
        }
    }
}

And I have also created a MovieActor model as a MySQLPivot for the relationship:

import Vapor
import FluentMySQL

final class MovieActor: MySQLPivot {

    typealias Left = Movie
    typealias Right = Actor

    static var leftIDKey: LeftIDKey = \.movieID
    static var rightIDKey: RightIDKey = \.actorID

    var id: Int?

    var movieID: Int
    var actorID: Int

    init(movieID: Int, actorID: Int) {
        self.movieID = movieID
        self.actorID = actorID
    }

}

extension MovieActor: MySQLMigration {}

And have added them to the migration section in the configure.swift file:

var migrations = MigrationConfig()
migrations.add(model: Movie.self, database: .mysql)
migrations.add(model: Actor.self, database: .mysql)
migrations.add(model: MovieActor.self, database: .mysql)
services.register(migrations)

All the tables in the database are being created just fine, but I am not receiving the relationship when calling the get all movies service. I am just receiving the Movie's properties:

final class MoviesController {

    func all(request: Request) throws -> Future<[Movie]> {

        return Movie.query(on: request).all()
    }

}

[
    {
        "id": 1,
        "dateReleased": "2017-11-20T00:00:00Z",
        "totalGrossed": 0,
        "name": "Star Wars: The Last Jedi",
        "synopsis": "Someone with a lightsaber kills another person with a lightsaber"
    },
    {
        "id": 3,
        "dateReleased": "1970-07-20T00:00:00Z",
        "totalGrossed": 0,
        "name": "Star Wars: A New Hope",
        "synopsis": "Someone new has been discovered by the force and he will kill the dark side with his awesome lightsaber and talking skills."
    },
    {
        "id": 4,
        "dateReleased": "2005-12-20T00:00:00Z",
        "totalGrossed": 100000000,
        "name": "Star Wars: Revenge of the Sith",
        "synopsis": "Anakin Skywalker being sliced by Obi-Wan Kenobi in an epic dual of fates"
    }
]

Your help would be appreciated! Thank you very much :)

like image 949
Erez Hod Avatar asked Jul 20 '18 16:07

Erez Hod


2 Answers

So I believe you're expecting the relationship to be reflected in what is returned when you query for a Movie model. So for example you expect something like this to be returned for a Movie:

{
    "id": 1,
    "dateReleased": "2017-11-20T00:00:00Z",
    "totalGrossed": 0,
    "name": "Star Wars: The Last Jedi",
    "synopsis": "Someone with a lightsaber kills another person with a lightsaber",
    "actors": [
        "id": 1,
        "firstName": "Leonardo",
        "lastName": "DiCaprio",
        "dateOfBirth": "1974-11-11T00:00:00Z",
        "story": "Couldn't get an Oscar until wrestling a bear for the big screen."
    ]
}

However, connecting the Movie and Actor models as siblings simply just gives you the convenience of being able to query the actors from a movie as if the actors were a property of the Movie model:

movie.actors.query(on: request).all()

That line above returns: Future<[Actor]>

This works vice versa for accessing the movies from an Actor object:

actor.movies.query(on: request).all()

That line above returns: Future<[Movie]>

If you wanted it to return both the movie and its actors in the same response like how I assumed you wanted it to work above, I believe the best way to do this would be creating a Content response struct like this:

struct MovieResponse: Content {

    let movie: Movie
    let actors: [Actor]
}

Your "all" function would now look like this:

func all(_ request: Request) throws -> Future<[MovieResponse]> {

    return Movie.query(on: request).all().flatMap { movies in

        let movieResponseFutures = try movies.map { movie in
            try movie.actors.query(on: request).all().map { actors in
                return MovieResponse(movie: movie, actors: actors)
            }
        }

        return movieResponseFutures.flatten(on: request)
    }
}

This function queries all of the movies and then iterates through each movie and then uses the "actors" sibling relation to query for that movie's actors. This actors query returns a Future<[Actor]> for each movie it queries the actors for. Map what is returned from the that relation so that you can access the actors as [Actor] instead of Future<[Actor]>, and then return that combined with the movie as a MovieResponse.

What this movieResponseFutures actually consists of is an array of MovieResponse futures: [Future<[MovieResponse]>]

To turn that array of futures into a single future that consists of an array you use flatten(on:). This waits waits for each of those individual futures to finish and then returns them all as a single future.

If you really wanted the Actor's array inside of the Movie object json, then you could structure the MovieResponse struct like this:

struct MovieResponse: Content {

    let id: Int?
    let name: String
    let synopsis: String
    let dateReleased: Date
    let totalGrossed: Float
    let actors: [Actor]

    init(movie: Movie, actors: [Actor]) {
        self.id = movie.id
        self.name = movie.name
        self.synopsis = movie.synopsis
        self.dateReleased = movie.dateReleased
        self.totalGrossed = movie.totalGrossed
        self.actors = actors
    }
}
like image 119
Vince Avatar answered Oct 01 '22 10:10

Vince


So the underlying issue here is that computed properties aren't provided in a Codable response. What you need to do is define a new type MoviesWithActors and populate that and return that. Or provide a second endpoint, something like /movies/1/actors/ that gets all the actors for a particular movie. That fits better with REST but it depends on your use case, as you may not want the extra requests etc

like image 24
0xTim Avatar answered Oct 01 '22 11:10

0xTim