chleba381
chleba381

Reputation: 17

How do i get instance of a model with highest value from an operation?

I have three models, directors, movies, and ratings. These are their relations: A director has_many movies, a movie belongs_to a director, a movie has_many ratings and a rating belongs_to a movie.

My schema.rb:

ActiveRecord::Schema.define(version: 2022_01_20_101906) do

  create_table "directors", force: :cascade do |t|
    t.string "first_name"
    t.date "date_of_birth"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "movies", force: :cascade do |t|
    t.string "name"
    t.date "release_date"
    t.string "description"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.integer "director_id"
    t.index ["director_id"], name: "index_movies_on_director_id"
  end

  create_table "ratings", force: :cascade do |t|
    t.integer "value"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.integer "movie_id"
    t.index ["movie_id"], name: "index_ratings_on_movie_id"
  end

  create_table "users", force: :cascade do |t|
    t.string "email", default: "", null: false
    t.string "encrypted_password", default: "", null: false
    t.string "reset_password_token"
    t.datetime "reset_password_sent_at", precision: 6
    t.datetime "remember_created_at", precision: 6
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
  end

  add_foreign_key "movies", "directors"
  add_foreign_key "ratings", "movies"
end

I'm currently trying to display a director's best-rated movie to display on a _director.html.erb patrial, so I tried this code to get the highest-rated movie value:

<% array = [] %>
<% rat_val = 0 %>
<% result = 0 %>
<% director.movies.each do |movie| %>
   <% if movie.ratings.count > 0 %>
      <% director.movies.each do |movie| %>
         <% rat_val = number_with_precision(movie.ratings.average(:value), precision: 2) %>
         <% array << rat_val %>
         <% result = array.max %>
      <% end %>
        <%= result %>
      <% break %>
   <% end %>
<% end %>

But I'm struggling with what to do next to use this value to get the director's best-rated movie name to display.

Upvotes: 0

Views: 143

Answers (4)

chleba381
chleba381

Reputation: 17

I managed to get it to work via this perhaps over-complicated loop:

  <% ratings_all = [] %>
  <% ratings_array = [] %>
  <% rating_avg = [] %>
  <% movies_rating = [] %>
  <% best_rating = 0 %>

  <% director.movies.each do |movie| %>

    <% if movie.ratings.count > 0 %>

      <% director.movies.each do |movie| %>

        <% ratings_all = movie.ratings.to_a %>
        <% ratings_array = ratings_all.map { |v| v[:value] } %>
        <% ratings_avg = (ratings_array.sum.to_f / ratings_array.count).round(2).to_f %>
        <% movies_rating << ratings_avg %>
        <% best_rating = movies_rating.max %>

      <% end %>

        <% if movie.ratings.average(:value).round(2) == best_rating %>
         <%= link_to movie.name, movie_path(movie) %>
         (<%= best_rating %>/5.0)

       <% end %>

    <% end %>

  <% end %>

but now it displays the name of the best rated movie on a directors and its rating, which is what I aimed to achieve.

Upvotes: 0

max
max

Reputation: 102055

A better performing alternative to a subquery is to use a lateral join:

SELECT "directors".*, 
       "best_rated"."average_rating" AS rating 
FROM "directors" 
JOIN LATERAL (
  SELECT AVG("ratings"."value") AS average_rating 
  FROM "movies" 
  INNER JOIN "ratings" ON "ratings"."movie_id" = "movies"."id" 
  WHERE "movies"."director_id" = "directors"."id" 
  GROUP BY "movies"."id" 
  ORDER BY "average_rating" DESC LIMIT 1
) best_rated ON true

This is somewhat like a foreach loop that selects a single row off the movies table which you can reference in the select clause. Writing the query with Arel/ActiveRecord is actually almost longer then the SQL string but is somewhat more portable.

class Director < ApplicationRecord
  has_many :movies
  has_many :ratings, through: :movies

  # Select directors and the average ranking of their top ranking movie
  def self.with_top_rated_movie
    # this is the subquery for the lateral join
    lateral = Movie
                .select(
                  Rating.arel_table[:value].average.as('average_rating')
                  # You can add any additional columns you want from the movie here
                )
                .joins(:ratings)
                .group(:id)
                .order(average_rating: :desc)
                # WHERE movies.director_id = directors.id  This is our lateral reference
                .where(Movie.arel_table[:director_id].eq(arel_table[:id]))
                .limit(1)
    # This is the alias used by the lateral join
    best_rated = Movie.arel_table.alias('best_rated')
    select(
      arel_table[Arel.star],
      best_rated[:average_rating].as('rating')
      # You can add any additional columns you want from the movie here
    ).joins(
      "JOIN LATERAL (#{lateral.to_sql}) best_rated ON true"
    )
  end           
end

Upvotes: 1

Matt
Matt

Reputation: 518

I think the following would be clean, and sticks with using ActiveRecord:

director.movies.order(rating: :desc).first

Upvotes: -1

Sebasti&#225;n Palma
Sebasti&#225;n Palma

Reputation: 33420

One way to obtain the best-rated movie from each director would be selecting the average rating values for each director movie, ordering by that calculation and limiting the rows returned to 1.

This would be very complex to achieve in ActiveRecord. Having the whole select statement to get the movie_name as a string, so here's just the SQL version:

SELECT
  d.*,
  (
    SELECT t.name
    FROM (
      SELECT name, (SELECT AVG(value) FROM ratings WHERE movie_id = movies.id) avgval
      FROM movies
      WHERE director_id = d.id
      ORDER BY avgval DESC
      LIMIT 1
    ) t
  ) movie_name
FROM directors d;

With this you don't need to manually iterate and counting raitings, you have every director column accessible plus the movie_name.

Upvotes: 1

Related Questions