There are Movies, which have a title (and more properties)
There are Users, which have a name (and more properties)
Users can “own” zero to many movies
Every user can rate every movie (even the ones they don’t own), and their rating is remembered on a per-user basis
This is the best I came up with:
type alias User =
{
id: Int
, name: String
-- More potential props here
}
type alias Movie =
{
id: Int
, title: String
-- More potential props here
}
type alias MovieUserData =
{
movieId: Int
, userId: Int
, isOwned: Bool
, rating: Int
}
-- In addition to storing Users and Movies, I store a Set of MovieUserData to capture their relationships
But it feels clunky and not very Elmy, and of course illegal states are still possible. What would be the best way to represent this situation in elm?
In this case as owned and rated movies are indepent things and they related to a user, User type itself could have fields: owned : Set MovieId, rated : Dict MovieId Rating. Also try not use use primitive types (String, Int) for representing domain data, use custom types.
But the actual structure should depend on functionality you need to implement in your app.
There are Movies , which have a title (and more properties)
type alias MovieId = Int
type alias Movie = {
id : MovieId,
title : String,
plot : String,
}
There are Users , which have a name (and more properties)
type alias User = {
id : Int,
firstName : String,
lastName : String,
}
Users can “own” zero to many movies
type Owned =
Zero
| Many MovieId (List MovieId)
# Now Change User record to the following
type alias User = {
id : Int,
firstName : String,
lastName : String,
owned : Owned
}
Every user can rate every movie (even the ones they don’t own), and their rating is remembered on a per-user basis
type alias User = {
id : Int,
firstName : String,
lastName : String,
owned : Owned,
ratedMovies : List MovieId
}
Since you both used ID based solutions: Is there a way to ensure consistency? For example, how to make sure that a user doesn’t “own” a movieId which doesn’t exist?
If thats the case then the Movies type should have fields called owned : List UserId, ratedBy : List UserId not the User type. This will stay consistent as long as User is not removed or deleted.
I don’t think there’s any good way of modelling away your “impossible state” without introducing a tighter (and probably undesired) coupling between Movie and User. You are dealing with two distinct entities. Consistency between them isn’t guaranteed at a type level.
I’d ask myself whether the state where a non-existing movie is owned actually incurs a problem. What does it mean for a user to own a movie that does not seem to exist? Maybe it means that the movie did exist at some point in the past. It doesn’t necessarily have to be an impossible state. Here’s what I’d probably do:
type alias User =
{ id : Int
, name : String
, ratedMovies : Dict Int Int
, ownedMovies : Set Int
}
type alias Movie =
{ id : Int
, rating : Int
}
-- Movies that are owned, but not found, simply get filtered out
ownedMovies : User -> List Movie -> List Movie
ownedMovies user allMovies =
...
ratedMovies : User -> List Movie -> List ( Movie, Int )
ratedMovies user allMovies =
...
If it’s really important to handle the case of non-existing movies, the functions could return Results instead:
type IntersectionError
= MovieDoesNotExist Int
ownedMovies : User -> List Movie -> Result IntersectionError (List Movie)
ownedMovies user allMovies =
...
ratedMovies : User -> List Movie -> Result IntersectionError (List ( Movie, Int ))
ratedMovies user allMovies =
...
And as mentioned by @romper, it might be a good idea to introduce more domain-specific types. Otherwise, your code conveys that a rating can be any Int in the range of -2^31 - 2^31 - 1
In this approach you don’t enforce it in model because maintaining proper relations would take quite some code and unless what you are doing is a MySQL competitor just not worth it. If user rated movie while it was available then keep it and filter out all deleted ones in rendering.
Adding to what @alexander-js said: Isn’t the set of movies that exist really the set of movies that someone owns? If I make a vacation movie on my phone, is that a legit movie that some people (my hobby filmmakers’ club) are allowed to rate? It depends on your use case but I think it should be allowed to exist outside a centralized notion of legit movies. The list of movies is just a foldl over owned movies.
The real question is, though, how to avoid two different movies with clashing IDs. UUIDs are a solution to that but may be too heavyweight in a simple use case.
type alias User =
{ id : Int
, name : String
}
type alias Movie =
{ id : Int
, rating : Int
, ownedBy : Set Int
, ratedBy : Dict Int Int
}
Strictly speaking, this coupling isn’t really tighter, the responsibilities are just flipped. Rather than User depending on Movie, Movie depends on User. This means that a Movie can’t exist without the notion of a User. This removes the problematic state of “a user owning a movie that does not exist”. However, it introduces the possible state of “a movie being owned by a user that does not exist”. It merely exchanges the problem for another.
Another way of modelling the solution, as per what @hasko wrote:
type alias User =
{ id : Int
, name : String
, ratedMovies : Dict Int Movie
, ownedMovies : List Movie
}
type alias Movie =
{ id : Int
, rating : Int
}
allMovies : List User -> List Movie
allMovies users =
...
This approach derives all movies from users. This is (potentially) problematic because it assumes that all movies are owned. It implicitly couples Movie to the notion of ownership.
You could complement this by fetching unowned movies separately and merging them:
unownedMovies : List Movie
unownedMovies =
...
ownedMovies : List User -> List Movie
ownedMovies users =
...
{-| Combine all owned movies with unowned movies -}
allMovies : List Movie
allMovies =
...
This renders the state of “a user owning a movie that does not exist” impossible. It also introduces duplication of data. Suppose the same movie exists in both User.ratedMovies and unownedMovies. Which one should be used in allMovies?
@alexander-js Thank you so much for the detailed and very helpful response! I totally understand what you mean now. I think this is the problem of access path dependence, that structs generally have. But I know what to pick now for my use case (probably going with the first solution you posted).
@hasko Thanks for your comment. In my case, not all movies have to be owned necessarily. Ownership is more like saying “I have this movie in my library”. So it’s very possible that some movies could be unowned entirely (it’s actually the starting state for all movies).
@opsb Thank you very much for linking that thread and video. I watched the entire video and it was exactly what I needed. @rtfeldman is really an amazing teacher. I do have a question though: On the slide at 26:29 in the video, he stores the data as follows:
type alias User =
{ userId : UserId
, age : Int
}
type alias Model =
{ users : Dict UserId User
, residents : Dict UserId CityId
}
Isn’t this again creating duplicated data (the UserId), once as the key to the usersDict in the model, and once in the User record? Is there a reason why he’s doing this? It confused me because just a few slides earlier, he had some very similar data structures, and there he did not do that (basically the userId property was omitted).